From 2e101afaa3723915fdf56a64fa4070679ab1bef9 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Wed, 12 Aug 2026 10:50:41 -0500 Subject: [PATCH 01/34] fix(biochem): query only parent docs on the nested Solr schema getReactions/getCompounds now detect the Solr-9 nested-document schema (parent reaction/compound docs plus [child] rows) via a cached, one-time probe per collection and add a doc_type parent filter when detected, so list queries return only parent docs instead of duplicated child rows. - lib/api/solrSchema.ts (new): parentDocTypeFilter, hasNestedSchema (probe + cache, never rejects), resetSolrSchemaCache (test-only). - lib/api/config.ts: SOLR_NESTED_SCHEMA_OVERRIDE tri-state manual override read from NEXT_PUBLIC_SOLR_NESTED_SCHEMA. - lib/api/biochem.ts: SolrQueryOpts gains optional filterQueries; buildSolrUrl appends fq= per entry; getReactions/getCompounds await hasNestedSchema and add the parent-doc filter only when nested. Behavior against the legacy schema is unchanged. - tests: new lib/api/solrSchema.test.ts; tests/unit/api/biochem.test.ts updated for the extra probe fetch call and covers both schema paths. --- .env.example | 9 ++ lib/api/biochem.ts | 27 +++++- lib/api/config.ts | 15 ++++ lib/api/solrSchema.ts | 70 +++++++++++++++ tests/unit/api/biochem.test.ts | 127 +++++++++++++++++++++----- tests/unit/api/solrSchema.test.ts | 143 ++++++++++++++++++++++++++++++ 6 files changed, 365 insertions(+), 26 deletions(-) create mode 100644 lib/api/solrSchema.ts create mode 100644 tests/unit/api/solrSchema.test.ts diff --git a/.env.example b/.env.example index eb42405e..1a88cbd6 100644 --- a/.env.example +++ b/.env.example @@ -136,6 +136,15 @@ NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_STAGING=compounds_staging NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION_PRODUCTION=reactions NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_PRODUCTION=compounds +# ============================================================================= +# SOLR NESTED SCHEMA OVERRIDE +# ============================================================================= +# Whether the Solr reactions/compounds collections use the Solr-9 nested- +# document schema (parent docs plus [child] rows). Unset = auto-detect via a +# one-time probe query per collection. true/1 = force nested-schema queries +# (parent docs only). false/0 = force legacy (flat) behavior. +NEXT_PUBLIC_SOLR_NESTED_SCHEMA= + # ============================================================================= # FEATURE FLAGS # ============================================================================= diff --git a/lib/api/biochem.ts b/lib/api/biochem.ts index d5782852..aa5cdd93 100644 --- a/lib/api/biochem.ts +++ b/lib/api/biochem.ts @@ -17,6 +17,7 @@ import { SOLR_COMPOUNDS_COLLECTION, SOLR_REACTIONS_COLLECTION, } from './config'; +import { hasNestedSchema, parentDocTypeFilter } from './solrSchema'; /* ─── Types ──────────────────────────────────────────────────── */ @@ -95,6 +96,8 @@ export interface SolrQueryOpts { queryColumn?: Record; visible?: string[]; filterModel?: GridFilterModel; + /** Raw Solr `fq` clauses, appended in order (e.g. a nested-schema parent-doc filter). */ + filterQueries?: string[]; } /* ─── External DB Links ──────────────────────────────────────── */ @@ -381,6 +384,7 @@ function buildSolrUrl(collection: string, opts: SolrQueryOpts = {}): string { queryColumn, visible = [], filterModel, + filterQueries = [], } = opts; // Field list @@ -388,6 +392,11 @@ function buildSolrUrl(collection: string, opts: SolrQueryOpts = {}): string { url += `&fl=${visible.join(',')}`; } + // Explicit filter queries (e.g. nested-schema parent-doc filter) + for (const fq of filterQueries) { + url += `&fq=${encodeURIComponent(fq)}`; + } + // Filter out ontology field for compounds (Solr compounds_staging has no ontology field) const filterItems = (filterModel?.items ?? []).filter(item => { const field = toSolrField(String(item.field ?? '')); @@ -889,7 +898,14 @@ export async function getReactions(opts: SolrQueryOpts = {}): Promise(url); // Mark obsolete reactions (matching legacy logic) @@ -930,7 +946,14 @@ export async function getCompounds(opts: SolrQueryOpts = {}): Promise(url); } diff --git a/lib/api/config.ts b/lib/api/config.ts index 82e5aa4b..79e123d6 100644 --- a/lib/api/config.ts +++ b/lib/api/config.ts @@ -35,6 +35,7 @@ const PUBLIC_ENV = { NEXT_PUBLIC_USE_MODELSEED_API: process.env.NEXT_PUBLIC_USE_MODELSEED_API, NEXT_PUBLIC_USE_NEW_PROXY: process.env.NEXT_PUBLIC_USE_NEW_PROXY, NEXT_PUBLIC_PROBMODELSEED_URL: process.env.NEXT_PUBLIC_PROBMODELSEED_URL, + NEXT_PUBLIC_SOLR_NESTED_SCHEMA: process.env.NEXT_PUBLIC_SOLR_NESTED_SCHEMA, } as const; type PublicEnvKey = keyof typeof PUBLIC_ENV; @@ -294,6 +295,20 @@ export function getSolrCollection(collection: 'reactions' | 'compounds'): string return collection === 'reactions' ? SOLR_REACTIONS_COLLECTION : SOLR_COMPOUNDS_COLLECTION; } +function readTriStateBooleanEnv(name: string): boolean | null { + const raw = readEnvSafe(name); + if (raw === 'true' || raw === '1') return true; + if (raw === 'false' || raw === '0') return false; + return null; +} + +/** + * Manual override for whether the Solr biochem collections use the Solr-9 + * nested-document schema. `null` means unset/unparseable, in which case + * callers should auto-detect (see `lib/api/solrSchema.ts`). + */ +export const SOLR_NESTED_SCHEMA_OVERRIDE = readTriStateBooleanEnv('NEXT_PUBLIC_SOLR_NESTED_SCHEMA'); + /* ─── modelseed_support (RAST Jobs) ─────────────────────────── */ export const MODELSEED_SUPPORT_URL = 'https://modelseed.org/services/ms_fba'; diff --git a/lib/api/solrSchema.ts b/lib/api/solrSchema.ts new file mode 100644 index 00000000..eddfe41f --- /dev/null +++ b/lib/api/solrSchema.ts @@ -0,0 +1,70 @@ +/** + * Solr-9 nested-schema detection for the biochem reactions/compounds + * collections. + * + * Legacy Solr indexes reactions/compounds as flat documents. The Solr-9 + * index nests child rows (e.g. per-alias, per-structure) under a parent + * document, so list queries must add a `doc_type:*` filter to avoid + * returning child rows alongside parents. Detection is a cheap one-time + * probe per collection, cached for the process lifetime, with a manual + * override for deployments that already know their schema. + */ + +import { getSolrCollection, SOLR_BASE, SOLR_NESTED_SCHEMA_OVERRIDE } from './config'; + +export type BiochemCollection = 'reactions' | 'compounds'; + +/** + * Solr filter query that restricts results to top-level (parent) documents + * on the Solr-9 nested schema. + */ +export function parentDocTypeFilter(collection: BiochemCollection): string { + return collection === 'reactions' ? 'doc_type:reaction' : 'doc_type:compound'; +} + +const schemaCache = new Map>(); +let hasWarnedOnProbeFailure = false; + +async function probeNestedSchema(collection: BiochemCollection): Promise { + try { + const url = `${SOLR_BASE}${getSolrCollection(collection)}/select?wt=json&rows=0&q=*:*&fq=${encodeURIComponent(parentDocTypeFilter(collection))}`; + const res = await fetch(url); + if (!res.ok) return false; + const json = await res.json(); + const numFound = json?.response?.numFound ?? 0; + return numFound > 0; + } catch (err) { + if (!hasWarnedOnProbeFailure) { + hasWarnedOnProbeFailure = true; + console.warn('Solr nested-schema probe failed; assuming legacy schema.', err); + } + return false; + } +} + +/** + * Resolves whether `collection` uses the Solr-9 nested-document schema. + * Honors `SOLR_NESTED_SCHEMA_OVERRIDE` when set (no network call). Otherwise + * probes once per collection and caches the result (including concurrent + * in-flight callers) for the process lifetime. Never rejects. + */ +export async function hasNestedSchema(collection: BiochemCollection): Promise { + if (SOLR_NESTED_SCHEMA_OVERRIDE !== null) { + return SOLR_NESTED_SCHEMA_OVERRIDE; + } + + const cached = schemaCache.get(collection); + if (cached) return cached; + + const probe = probeNestedSchema(collection); + schemaCache.set(collection, probe); + return probe; +} + +/** + * Test-only reset of the module-level probe cache. + */ +export function resetSolrSchemaCache(): void { + schemaCache.clear(); + hasWarnedOnProbeFailure = false; +} diff --git a/tests/unit/api/biochem.test.ts b/tests/unit/api/biochem.test.ts index b32b0f12..94e4a950 100644 --- a/tests/unit/api/biochem.test.ts +++ b/tests/unit/api/biochem.test.ts @@ -87,11 +87,13 @@ describe('getCompounds Solr query shape', () => { it('quick search must not reference ontology (undefined field on compounds_staging)', async () => { const biochemApi = await loadBiochemApi(); - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify({ response: { numFound: 0, start: 0, docs: [] } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ response: { numFound: 0, start: 0, docs: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), ); await biochemApi.getCompounds({ @@ -101,7 +103,9 @@ describe('getCompounds Solr query shape', () => { }); expect(fetchMock).toHaveBeenCalled(); - const calledUrl = String(fetchMock.mock.calls[0]?.[0] ?? ''); + // The Solr-9 nested-schema probe (a separate `select?...&fq=doc_type:...` request) + // runs before the real list query, so assert on the *last* fetch call. + const calledUrl = String(fetchMock.mock.calls.at(-1)?.[0] ?? ''); const u = new URL(calledUrl); const qRaw = u.searchParams.get('q'); expect(qRaw).toBeTruthy(); @@ -119,11 +123,13 @@ describe('getReactions Solr case-variant filters', () => { it('expands lowercase equals filters with case variants', async () => { const biochemApi = await loadBiochemApi(); - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify({ response: { numFound: 0, start: 0, docs: [] } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ response: { numFound: 0, start: 0, docs: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), ); await biochemApi.getReactions({ @@ -137,7 +143,8 @@ describe('getReactions Solr case-variant filters', () => { }); expect(fetchMock).toHaveBeenCalled(); - const calledUrl = String(fetchMock.mock.calls[0]?.[0] ?? ''); + // The Solr-9 nested-schema probe runs before the real list query. + const calledUrl = String(fetchMock.mock.calls.at(-1)?.[0] ?? ''); const q = decodeURIComponent(new URL(calledUrl).searchParams.get('q') ?? ''); expect(q).toContain('status:"ok"'); expect(q).toContain('status:"OK"'); @@ -156,17 +163,20 @@ describe('Solr collection routing', () => { vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'production'); vi.stubEnv('NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION', 'reactions'); vi.stubEnv('NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION', 'compounds'); - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify({ response: { numFound: 0, start: 0, docs: [] } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ response: { numFound: 0, start: 0, docs: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), ); const api = await import('@/lib/api/biochem'); await api.getReactions({ limit: 1 }); - const calledUrl = String(fetchMock.mock.calls[0]?.[0] ?? ''); + // The Solr-9 nested-schema probe runs before the real list query. + const calledUrl = String(fetchMock.mock.calls.at(-1)?.[0] ?? ''); expect(calledUrl).toContain('/reactions/select'); expect(calledUrl).not.toContain('/reactions_staging/select'); }); @@ -176,17 +186,86 @@ describe('Solr collection routing', () => { vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'production'); vi.stubEnv('NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION', 'reactions_custom'); vi.stubEnv('NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION', 'compounds'); - const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify({ response: { numFound: 0, start: 0, docs: [] } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ response: { numFound: 0, start: 0, docs: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), ); const api = await import('@/lib/api/biochem'); await api.getReactions({ limit: 1 }); - const calledUrl = String(fetchMock.mock.calls[0]?.[0] ?? ''); + // The Solr-9 nested-schema probe runs before the real list query. + const calledUrl = String(fetchMock.mock.calls.at(-1)?.[0] ?? ''); expect(calledUrl).toContain('/reactions_custom/select'); }); }); + +describe('getReactions/getCompounds nested-schema parent-doc filter', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('adds no fq when the nested-schema probe finds no parent docs (legacy schema)', async () => { + const biochemApi = await loadBiochemApi(); + const { resetSolrSchemaCache } = await import('@/lib/api/solrSchema'); + resetSolrSchemaCache(); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ response: { numFound: 0, start: 0, docs: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + await biochemApi.getReactions({ limit: 5 }); + + // One probe call plus the real list query. + expect(fetchMock).toHaveBeenCalledTimes(2); + const listUrl = String(fetchMock.mock.calls.at(-1)?.[0] ?? ''); + expect(listUrl).not.toContain('fq='); + }); + + it('adds a doc_type:reaction fq when the nested-schema probe finds parent docs', async () => { + const biochemApi = await loadBiochemApi(); + const { resetSolrSchemaCache } = await import('@/lib/api/solrSchema'); + resetSolrSchemaCache(); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ response: { numFound: 1, start: 0, docs: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + await biochemApi.getReactions({ limit: 5 }); + + const listUrl = String(fetchMock.mock.calls.at(-1)?.[0] ?? ''); + expect(listUrl).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`); + }); + + it('adds a doc_type:compound fq when the nested-schema probe finds parent docs', async () => { + const biochemApi = await loadBiochemApi(); + const { resetSolrSchemaCache } = await import('@/lib/api/solrSchema'); + resetSolrSchemaCache(); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ response: { numFound: 1, start: 0, docs: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + await biochemApi.getCompounds({ limit: 5 }); + + const listUrl = String(fetchMock.mock.calls.at(-1)?.[0] ?? ''); + expect(listUrl).toContain(`fq=${encodeURIComponent('doc_type:compound')}`); + }); +}); diff --git a/tests/unit/api/solrSchema.test.ts b/tests/unit/api/solrSchema.test.ts new file mode 100644 index 00000000..ec20f23a --- /dev/null +++ b/tests/unit/api/solrSchema.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; + +async function loadSolrSchema() { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'staging'); + return import('@/lib/api/solrSchema'); +} + +function mockFetchOnce(numFound: number, status = 200) { + // Use a factory so each call gets a fresh Response (bodies can only be read once). + return vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ response: { numFound, start: 0, docs: [] } }), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); +} + +describe('parentDocTypeFilter', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('returns doc_type:reaction for reactions', async () => { + const { parentDocTypeFilter } = await loadSolrSchema(); + expect(parentDocTypeFilter('reactions')).toBe('doc_type:reaction'); + }); + + it('returns doc_type:compound for compounds', async () => { + const { parentDocTypeFilter } = await loadSolrSchema(); + expect(parentDocTypeFilter('compounds')).toBe('doc_type:compound'); + }); +}); + +describe('hasNestedSchema override', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('returns true immediately from the override without a network call', async () => { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'staging'); + vi.stubEnv('NEXT_PUBLIC_SOLR_NESTED_SCHEMA', 'true'); + const fetchMock = vi.spyOn(globalThis, 'fetch'); + const { hasNestedSchema } = await import('@/lib/api/solrSchema'); + + await expect(hasNestedSchema('reactions')).resolves.toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns false immediately from the override without a network call', async () => { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'staging'); + vi.stubEnv('NEXT_PUBLIC_SOLR_NESTED_SCHEMA', 'false'); + const fetchMock = vi.spyOn(globalThis, 'fetch'); + const { hasNestedSchema } = await import('@/lib/api/solrSchema'); + + await expect(hasNestedSchema('compounds')).resolves.toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('hasNestedSchema probe', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('resolves true when the probe finds parent docs', async () => { + const { hasNestedSchema } = await loadSolrSchema(); + mockFetchOnce(1); + + await expect(hasNestedSchema('reactions')).resolves.toBe(true); + }); + + it('resolves false when the probe finds no docs', async () => { + const { hasNestedSchema } = await loadSolrSchema(); + mockFetchOnce(0); + + await expect(hasNestedSchema('reactions')).resolves.toBe(false); + }); + + it('resolves false (never rejects) on an HTTP error response', async () => { + const { hasNestedSchema } = await loadSolrSchema(); + mockFetchOnce(1, 500); + + await expect(hasNestedSchema('compounds')).resolves.toBe(false); + }); + + it('resolves false (never rejects) when fetch throws', async () => { + const { hasNestedSchema } = await loadSolrSchema(); + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')); + + await expect(hasNestedSchema('compounds')).resolves.toBe(false); + }); + + it('probes each collection only once and shares the result across concurrent callers', async () => { + const { hasNestedSchema } = await loadSolrSchema(); + const fetchMock = mockFetchOnce(1); + + const [a, b] = await Promise.all([hasNestedSchema('reactions'), hasNestedSchema('reactions')]); + expect(a).toBe(true); + expect(b).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // A subsequent call reuses the cached result; still only one fetch total. + await hasNestedSchema('reactions'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('probes reactions and compounds independently', async () => { + const { hasNestedSchema } = await loadSolrSchema(); + const fetchMock = mockFetchOnce(1); + + await hasNestedSchema('reactions'); + await hasNestedSchema('compounds'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('resetSolrSchemaCache', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('forces a fresh probe on the next call', async () => { + const { hasNestedSchema, resetSolrSchemaCache } = await loadSolrSchema(); + const fetchMock = mockFetchOnce(1); + + await hasNestedSchema('reactions'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resetSolrSchemaCache(); + await hasNestedSchema('reactions'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); From 2663575097dd9f52253648a7d91e0984f951991a Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Wed, 12 Aug 2026 11:55:43 -0500 Subject: [PATCH 02/34] feat(biochem): expose Solr 9 nested thermodynamics records and atom mapping fields --- lib/api/biochem.ts | 81 ++++++++++++- lib/utils/atomMapping.ts | 170 +++++++++++++++++++++++++++ tests/unit/api/biochemThermo.test.ts | 129 ++++++++++++++++++++ tests/unit/utils/atomMapping.test.ts | 158 +++++++++++++++++++++++++ 4 files changed, 534 insertions(+), 4 deletions(-) create mode 100644 lib/utils/atomMapping.ts create mode 100644 tests/unit/api/biochemThermo.test.ts create mode 100644 tests/unit/utils/atomMapping.test.ts diff --git a/lib/api/biochem.ts b/lib/api/biochem.ts index aa5cdd93..6f29fd55 100644 --- a/lib/api/biochem.ts +++ b/lib/api/biochem.ts @@ -21,6 +21,13 @@ import { hasNestedSchema, parentDocTypeFilter } from './solrSchema'; /* ─── Types ──────────────────────────────────────────────────── */ +export interface ThermodynamicsRecord { + source_name: string; + energy: number | null; + error: number | null; + operator?: string; +} + export interface Reaction { id: string; name: string; @@ -42,6 +49,12 @@ export interface Reaction { compound_ids?: string[]; linked_reaction?: string; source?: string; + thermodynamics?: ThermodynamicsRecord[]; + n_sources_thermodynamics?: number; + sources_agree_direction?: boolean; + atom_mapping?: string[]; + atom_mapping_confidence?: string; + has_atom_mapping?: boolean; } export interface Compound { @@ -64,6 +77,10 @@ export interface Compound { pkb?: string[]; source?: string; structure?: string; + thermodynamics?: ThermodynamicsRecord[]; + n_sources_thermodynamics?: number; + pka_value?: string[]; + pkb_value?: string[]; } export interface GridFilterItem { @@ -807,6 +824,52 @@ function sortDocs( }); } +/** Coerces a Solr thermodynamics child's `energy`/`error` value to a finite number or null. */ +function coerceThermodynamicsNumber(value: unknown): number | null { + const raw = Array.isArray(value) ? value[0] : value; + const num = Number(raw); + return Number.isFinite(num) ? num : null; +} + +/** + * Normalizes the raw Solr-9 nested `thermodynamics` child documents (or the + * legacy `_childDocuments_` shape) attached to a reaction/compound doc into + * a flat, typed `ThermodynamicsRecord[]`. Pure and never throws: malformed + * or missing input yields `[]`. + */ +export function normalizeThermodynamics(doc: unknown): ThermodynamicsRecord[] { + if (!doc || typeof doc !== 'object') return []; + const record = doc as Record; + const children = Array.isArray(record.thermodynamics) + ? record.thermodynamics + : Array.isArray(record._childDocuments_) + ? record._childDocuments_ + : []; + + const results: ThermodynamicsRecord[] = []; + for (const child of children) { + if (!child || typeof child !== 'object') continue; + const c = child as Record; + + const docType = c.doc_type; + if (typeof docType === 'string' && docType !== 'thermodynamics') continue; + + const sourceName = c.source_name; + if (typeof sourceName !== 'string' || sourceName.length === 0) continue; + + const entry: ThermodynamicsRecord = { + source_name: sourceName, + energy: coerceThermodynamicsNumber(c.energy), + error: coerceThermodynamicsNumber(c.error), + }; + if (typeof c.operator === 'string' && c.operator.length > 0) { + entry.operator = c.operator; + } + results.push(entry); + } + return results; +} + /** * Apply MUI column filter items to row objects locally (for APIs that cannot express filters server-side). * Uses the same operator semantics as Solr-backed biochem when used with `get*FromModelseedApi`. @@ -1010,9 +1073,14 @@ export async function getCompoundsFromModelseedApi( */ export async function getReactionById(id: string): Promise { // Keep detail lookups on legacy Solr until modelseed-api exposes an ID endpoint. - const url = `${SOLR_BASE_LEGACY}${SOLR_REACTIONS_COLLECTION}/select?wt=json&q=id:${id}`; + let url = `${SOLR_BASE_LEGACY}${SOLR_REACTIONS_COLLECTION}/select?wt=json&q=id:${id}`; + const nested = await hasNestedSchema('reactions'); + if (nested) { + url += `&fq=${encodeURIComponent(parentDocTypeFilter('reactions'))}&fl=${encodeURIComponent('*,[child childFilter=doc_type:thermodynamics]')}`; + } const res = await fetchSolr(url); - return res.docs[0]; + const raw = res.docs[0]; + return raw ? { ...raw, thermodynamics: normalizeThermodynamics(raw) } : raw; } /** @@ -1030,9 +1098,14 @@ export async function getReactionById(id: string): Promise { */ export async function getCompoundById(id: string): Promise { // Keep detail lookups on legacy Solr until modelseed-api exposes an ID endpoint. - const url = `${SOLR_BASE_LEGACY}${SOLR_COMPOUNDS_COLLECTION}/select?wt=json&q=id:${id}`; + let url = `${SOLR_BASE_LEGACY}${SOLR_COMPOUNDS_COLLECTION}/select?wt=json&q=id:${id}`; + const nested = await hasNestedSchema('compounds'); + if (nested) { + url += `&fq=${encodeURIComponent(parentDocTypeFilter('compounds'))}&fl=${encodeURIComponent('*,[child childFilter=doc_type:thermodynamics]')}`; + } const res = await fetchSolr(url); - return res.docs[0]; + const raw = res.docs[0]; + return raw ? { ...raw, thermodynamics: normalizeThermodynamics(raw) } : raw; } /** diff --git a/lib/utils/atomMapping.ts b/lib/utils/atomMapping.ts new file mode 100644 index 00000000..41695f63 --- /dev/null +++ b/lib/utils/atomMapping.ts @@ -0,0 +1,170 @@ +/** + * Parse the Solr-9 reaction `atom_mapping` field into typed, grouped atom-pair + * records. + * + * Wire format: the field is an array of strings, ONE ATOM PAIR PER ELEMENT, + * shaped as: + * + * cpdAAAAA:E#N=cpdBBBBB:E#M + * + * where `E` is an element symbol (e.g. `O`, `H`, `Mg`) and `N`/`M` are + * 1-based atom indices. The raw upstream `.txt` export prefixes each line + * with a reaction id token (`rxn00001 cpd00001:O#1=cpd00009:O#2`); the Solr + * field itself may or may not carry that prefix, so both forms are accepted. + * + * IMPORTANT: `N`/`M` are counted per element, per compound, in InChI + * canonical atom order (i.e. "the 3rd oxygen of cpd00012" per the InChI + * canonicalization), NOT a SMILES atom index and NOT an RDKit atom map + * number. Do not feed these indices directly into an RDKit/SMILES atom + * index without an explicit InChI-order mapping step. + * + * This module is pure and dependency-free: no I/O, no React, no RDKit, no + * config, no fetch, no module-level mutable state. Malformed input degrades + * to a shorter result (or `null`/`[]`), never to a thrown exception. + */ + +/** One atom referenced by an `atom_mapping` entry. */ +export interface AtomRef { + compoundId: string; + element: string; + index: number; +} + +/** A single parsed atom-pair entry from the `atom_mapping` array. */ +export interface AtomMappingPair { + left: AtomRef; + right: AtomRef; + raw: string; +} + +const LEADING_REACTION_ID = /^rxn\d+\s+/; +const ATOM_REF = /^([A-Za-z][A-Za-z0-9]*\d+):([A-Za-z][a-z]?)#(\d+)$/; + +/** + * Parse one side of an atom-mapping entry, e.g. `cpd00001:O#1`. + * Returns `null` for anything that does not match the expected shape, + * including a zero or non-numeric index. + */ +function parseAtomRef(side: string): AtomRef | null { + const match = ATOM_REF.exec(side); + if (!match) return null; + + const [, compoundId, element, indexText] = match; + const index = Number.parseInt(indexText, 10); + if (!Number.isFinite(index) || index <= 0) return null; + + return { compoundId, element, index }; +} + +/** + * Parse a single `atom_mapping` array entry into a typed pair. Never throws: + * any malformed input (wrong type, missing separators, unparsable sides) + * yields `null`. + */ +export function parseAtomMappingEntry(entry: string): AtomMappingPair | null { + if (typeof entry !== 'string') return null; + + const trimmed = entry.trim(); + if (!trimmed) return null; + + const raw = trimmed.replace(LEADING_REACTION_ID, ''); + + const parts = raw.split('='); + if (parts.length !== 2) return null; + + const left = parseAtomRef(parts[0]); + const right = parseAtomRef(parts[1]); + if (!left || !right) return null; + + return { left, right, raw }; +} + +/** + * Parse a full `atom_mapping` array, dropping unparsable entries while + * preserving the order of the entries that do parse. Missing/empty input + * (`undefined`, `null`) returns an empty array. + */ +export function parseAtomMappings( + entries: readonly string[] | undefined | null, +): AtomMappingPair[] { + if (!entries) return []; + + const pairs: AtomMappingPair[] = []; + for (const entry of entries) { + const pair = parseAtomMappingEntry(entry); + if (pair) pairs.push(pair); + } + return pairs; +} + +/** + * Group atom-mapping pairs by the compound(s) they reference. A pair whose + * `left` and `right` compound ids are equal is added to that compound's + * bucket exactly once (not twice); a pair spanning two different compounds + * appears under both. Key insertion order follows first appearance. + */ +export function groupAtomMappingsByCompound( + pairs: readonly AtomMappingPair[], +): Map { + const groups = new Map(); + + const addTo = (compoundId: string, pair: AtomMappingPair) => { + const bucket = groups.get(compoundId); + if (bucket) { + bucket.push(pair); + } else { + groups.set(compoundId, [pair]); + } + }; + + for (const pair of pairs) { + addTo(pair.left.compoundId, pair); + if (pair.right.compoundId !== pair.left.compoundId) { + addTo(pair.right.compoundId, pair); + } + } + + return groups; +} + +/** + * Count, per compound, the number of distinct atom indices seen for each + * element - considering both sides of every pair. An atom index that + * appears in more than one pair (e.g. it participates in multiple mapped + * bonds) is counted once. + */ +export function countAtomsPerElement( + pairs: readonly AtomMappingPair[], +): Map> { + const seen = new Map>>(); + + const record = (ref: AtomRef) => { + let byElement = seen.get(ref.compoundId); + if (!byElement) { + byElement = new Map>(); + seen.set(ref.compoundId, byElement); + } + let indices = byElement.get(ref.element); + if (!indices) { + indices = new Set(); + byElement.set(ref.element, indices); + } + indices.add(ref.index); + }; + + for (const pair of pairs) { + record(pair.left); + record(pair.right); + } + + const counts = new Map>(); + for (const [compoundId, byElement] of seen) { + const elementCounts = new Map(); + for (const [element, indices] of byElement) { + elementCounts.set(element, indices.size); + } + counts.set(compoundId, elementCounts); + } + + return counts; +} diff --git a/tests/unit/api/biochemThermo.test.ts b/tests/unit/api/biochemThermo.test.ts new file mode 100644 index 00000000..9ecda569 --- /dev/null +++ b/tests/unit/api/biochemThermo.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { resetSolrSchemaCache } from '@/lib/api/solrSchema'; + +async function loadBiochemApi() { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'staging'); + return import('@/lib/api/biochem'); +} + +/** Builds a fetch mock that answers the nested-schema probe and the id lookup differently by URL shape. */ +function mockFetch(opts: { nested: boolean; doc?: Record }) { + return vi.spyOn(globalThis, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + const isProbe = url.includes('rows=0'); + const body = isProbe + ? { response: { numFound: opts.nested ? 1 : 0, start: 0, docs: [] } } + : { response: { numFound: opts.doc ? 1 : 0, start: 0, docs: opts.doc ? [opts.doc] : [] } }; + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); +} + +describe('getReactionById / getCompoundById thermodynamics', () => { + beforeEach(() => { + resetSolrSchemaCache(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + resetSolrSchemaCache(); + }); + + it('normalizes 3 nested thermodynamics children for a reaction, preserving order and operator', async () => { + const biochemApi = await loadBiochemApi(); + const doc = { + id: 'rxn00001', + thermodynamics: [ + { doc_type: 'thermodynamics', source_name: 'eQuilibrator', energy: -10.5, error: 1.2, operator: '=' }, + { doc_type: 'thermodynamics', source_name: 'Alberty', energy: -9.1, error: 0.5, operator: '>' }, + { doc_type: 'thermodynamics', source_name: 'Jankowski', energy: -11.0, error: 2.0, operator: '<' }, + ], + }; + mockFetch({ nested: true, doc }); + + const result = await biochemApi.getReactionById('rxn00001'); + + expect(result.thermodynamics).toEqual([ + { source_name: 'eQuilibrator', energy: -10.5, error: 1.2, operator: '=' }, + { source_name: 'Alberty', energy: -9.1, error: 0.5, operator: '>' }, + { source_name: 'Jankowski', energy: -11.0, error: 2.0, operator: '<' }, + ]); + }); + + it('normalizes children found under legacy _childDocuments_ for a compound', async () => { + const biochemApi = await loadBiochemApi(); + const doc = { + id: 'cpd00001', + _childDocuments_: [ + { doc_type: 'thermodynamics', source_name: 'eQuilibrator', energy: -5, error: 0.1 }, + ], + }; + mockFetch({ nested: true, doc }); + + const result = await biochemApi.getCompoundById('cpd00001'); + + expect(result.thermodynamics).toEqual([ + { source_name: 'eQuilibrator', energy: -5, error: 0.1 }, + ]); + }); + + it('returns thermodynamics: [] and an unmodified URL on the legacy schema', async () => { + const biochemApi = await loadBiochemApi(); + const doc = { id: 'rxn00001' }; + const fetchMock = mockFetch({ nested: false, doc }); + + const result = await biochemApi.getReactionById('rxn00001'); + + expect(result.thermodynamics).toEqual([]); + const dataCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('rows=0')); + const dataUrl = String(dataCall?.[0]); + expect(dataUrl).not.toContain('fq='); + expect(dataUrl).not.toContain('fl='); + }); + + it('adds the encoded parent doc_type filter and [child] transformer on the nested schema', async () => { + const biochemApi = await loadBiochemApi(); + const doc = { id: 'rxn00001', thermodynamics: [] }; + const fetchMock = mockFetch({ nested: true, doc }); + + await biochemApi.getReactionById('rxn00001'); + + const dataCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('rows=0')); + const dataUrl = String(dataCall?.[0]); + expect(dataUrl).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`); + expect(dataUrl).toContain(`fl=${encodeURIComponent('*,[child childFilter=doc_type:thermodynamics]')}`); + }); + + it('drops malformed children and coerces array-wrapped/absent numeric values', async () => { + const biochemApi = await loadBiochemApi(); + const doc = { + id: 'rxn00001', + thermodynamics: [ + { doc_type: 'thermodynamics', energy: -1, error: 0.1 }, // missing source_name -> dropped + { doc_type: 'thermodynamics', source_name: 'good', energy: ['-1.5'] }, // error absent + { doc_type: 'other', source_name: 'wrong-type', energy: -2, error: 0.2 }, // wrong doc_type -> dropped + ], + }; + mockFetch({ nested: true, doc }); + + const result = await biochemApi.getReactionById('rxn00001'); + + expect(result.thermodynamics).toEqual([ + { source_name: 'good', energy: -1.5, error: null }, + ]); + }); + + it('normalizeThermodynamics(null | undefined | {} | 42) all return []', async () => { + const biochemApi = await loadBiochemApi(); + expect(biochemApi.normalizeThermodynamics(null)).toEqual([]); + expect(biochemApi.normalizeThermodynamics(undefined)).toEqual([]); + expect(biochemApi.normalizeThermodynamics({})).toEqual([]); + expect(biochemApi.normalizeThermodynamics(42)).toEqual([]); + }); +}); diff --git a/tests/unit/utils/atomMapping.test.ts b/tests/unit/utils/atomMapping.test.ts new file mode 100644 index 00000000..361a4e4e --- /dev/null +++ b/tests/unit/utils/atomMapping.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; +import { + countAtomsPerElement, + groupAtomMappingsByCompound, + parseAtomMappingEntry, + parseAtomMappings, +} from '@/lib/utils/atomMapping'; + +// Five real upstream lines (rxn00001), the last two sharing cpd00009:O#3. +const UPSTREAM_LINES = [ + 'rxn00001 cpd00001:O#1=cpd00009:O#2', + 'rxn00001 cpd00012:O#1=cpd00009:O#1', + 'rxn00001 cpd00012:O#2=cpd00009:O#2', + 'rxn00001 cpd00012:O#3=cpd00009:O#3', + 'rxn00001 cpd00012:O#4=cpd00009:O#3', +]; + +describe('parseAtomMappingEntry', () => { + it('parses each real upstream line with its leading reaction-id token', () => { + const parsed = UPSTREAM_LINES.map(parseAtomMappingEntry); + expect(parsed.every((p) => p !== null)).toBe(true); + + expect(parsed[0]).toEqual({ + left: { compoundId: 'cpd00001', element: 'O', index: 1 }, + right: { compoundId: 'cpd00009', element: 'O', index: 2 }, + raw: 'cpd00001:O#1=cpd00009:O#2', + }); + }); + + it('parses the same lines without the leading reaction-id token', () => { + const stripped = UPSTREAM_LINES.map((line) => + line.replace(/^rxn\d+\s+/, ''), + ); + const parsed = stripped.map(parseAtomMappingEntry); + expect(parsed.every((p) => p !== null)).toBe(true); + expect(parsed[4]).toEqual({ + left: { compoundId: 'cpd00012', element: 'O', index: 4 }, + right: { compoundId: 'cpd00009', element: 'O', index: 3 }, + raw: 'cpd00012:O#4=cpd00009:O#3', + }); + }); + + it('parses a two-character element symbol (Mg)', () => { + const pair = parseAtomMappingEntry('cpd00254:Mg#1=cpd00099:Mg#1'); + expect(pair).toEqual({ + left: { compoundId: 'cpd00254', element: 'Mg', index: 1 }, + right: { compoundId: 'cpd00099', element: 'Mg', index: 1 }, + raw: 'cpd00254:Mg#1=cpd00099:Mg#1', + }); + }); + + it('parses a self-pair where both sides share a compoundId', () => { + const pair = parseAtomMappingEntry('cpd00001:O#1=cpd00001:O#2'); + expect(pair?.left.compoundId).toBe('cpd00001'); + expect(pair?.right.compoundId).toBe('cpd00001'); + }); + + it('returns null instead of throwing for malformed or missing input', () => { + expect(parseAtomMappingEntry('')).toBeNull(); + expect(parseAtomMappingEntry(' ')).toBeNull(); + expect(parseAtomMappingEntry('garbage')).toBeNull(); + expect(parseAtomMappingEntry('cpd00001:O#1')).toBeNull(); // no '=' + expect(parseAtomMappingEntry('a=b=c')).toBeNull(); // too many '=' + expect(parseAtomMappingEntry('cpd00001:O#0=cpd00009:O#1')).toBeNull(); // zero index + expect(parseAtomMappingEntry('cpd00001:Oxx#1=cpd00009:O#1')).toBeNull(); // bad element + // @ts-expect-error - exercising the non-string runtime guard + expect(parseAtomMappingEntry(42)).toBeNull(); + // @ts-expect-error - exercising the non-string runtime guard + expect(parseAtomMappingEntry(null)).toBeNull(); + }); +}); + +describe('parseAtomMappings', () => { + it('returns [] for undefined, null, and empty input', () => { + expect(parseAtomMappings(undefined)).toEqual([]); + expect(parseAtomMappings(null)).toEqual([]); + expect(parseAtomMappings([])).toEqual([]); + }); + + it('drops unparsable entries (including non-string array members) while preserving order', () => { + const entries = [ + 'rxn00001 cpd00001:O#1=cpd00009:O#2', + 'garbage', + 42, + null, + 'rxn00001 cpd00012:O#1=cpd00009:O#1', + ] as unknown as string[]; + + const pairs = parseAtomMappings(entries); + expect(pairs).toHaveLength(2); + expect(pairs[0].raw).toBe('cpd00001:O#1=cpd00009:O#2'); + expect(pairs[1].raw).toBe('cpd00012:O#1=cpd00009:O#1'); + }); + + it('parses all five real upstream lines in order', () => { + const pairs = parseAtomMappings(UPSTREAM_LINES); + expect(pairs).toHaveLength(5); + expect(pairs.map((p) => p.raw)).toEqual( + UPSTREAM_LINES.map((l) => l.replace(/^rxn\d+\s+/, '')), + ); + }); +}); + +describe('groupAtomMappingsByCompound', () => { + it('groups a cross-compound pair under both compound ids', () => { + const pairs = parseAtomMappings(UPSTREAM_LINES); + const groups = groupAtomMappingsByCompound(pairs); + + expect(groups.get('cpd00001')).toHaveLength(1); + expect(groups.get('cpd00009')).toHaveLength(5); + expect(groups.get('cpd00012')).toHaveLength(4); + }); + + it('adds a self-pair to its shared compound bucket exactly once', () => { + const pairs = parseAtomMappings(['cpd00001:O#1=cpd00001:O#2']); + const groups = groupAtomMappingsByCompound(pairs); + + expect(groups.get('cpd00001')).toHaveLength(1); + }); + + it('follows first-appearance insertion order for keys', () => { + const pairs = parseAtomMappings(UPSTREAM_LINES); + const groups = groupAtomMappingsByCompound(pairs); + + expect(Array.from(groups.keys())).toEqual([ + 'cpd00001', + 'cpd00009', + 'cpd00012', + ]); + }); +}); + +describe('countAtomsPerElement', () => { + it('counts distinct atom indices per element per compound', () => { + const pairs = parseAtomMappings(UPSTREAM_LINES); + const counts = countAtomsPerElement(pairs); + + // cpd00009:O appears at indices 2, 1, 2, 3, 3 across the five lines -> 3 distinct. + expect(counts.get('cpd00009')?.get('O')).toBe(3); + // cpd00012:O appears at indices 1, 2, 3, 4 -> 4 distinct. + expect(counts.get('cpd00012')?.get('O')).toBe(4); + // cpd00001:O appears once. + expect(counts.get('cpd00001')?.get('O')).toBe(1); + }); + + it('does not double-count a repeated index seen across multiple pairs', () => { + const pairs = parseAtomMappings([ + 'cpd00012:O#3=cpd00009:O#3', + 'cpd00012:O#4=cpd00009:O#3', + ]); + const counts = countAtomsPerElement(pairs); + expect(counts.get('cpd00009')?.get('O')).toBe(1); + }); + + it('returns an empty map for an empty pair list', () => { + expect(countAtomsPerElement([]).size).toBe(0); + }); +}); From 1ebeca7529110d1ef9baf02d7ac3ce68338cf575 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Wed, 12 Aug 2026 11:55:50 -0500 Subject: [PATCH 03/34] feat(biochem): render all thermodynamics records and atom mappings on detail pages --- .../biochem/compounds/[id]/page.tsx | 44 ++++++--- .../biochem/reactions/[id]/page.tsx | 54 +++++++++- components/ui/AtomMappingSummary.tsx | 98 +++++++++++++++++++ components/ui/ThermodynamicsTable.tsx | 55 +++++++++++ .../components/AtomMappingSummary.test.tsx | 62 ++++++++++++ .../components/ThermodynamicsTable.test.tsx | 69 +++++++++++++ 6 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 components/ui/AtomMappingSummary.tsx create mode 100644 components/ui/ThermodynamicsTable.tsx create mode 100644 tests/unit/components/AtomMappingSummary.test.tsx create mode 100644 tests/unit/components/ThermodynamicsTable.test.tsx diff --git a/app/(reference-data)/biochem/compounds/[id]/page.tsx b/app/(reference-data)/biochem/compounds/[id]/page.tsx index 17d5a494..99d54089 100644 --- a/app/(reference-data)/biochem/compounds/[id]/page.tsx +++ b/app/(reference-data)/biochem/compounds/[id]/page.tsx @@ -20,6 +20,7 @@ import { } from '@/lib/api/biochem'; import { formatFormula } from '@/components/utils/formatFormula'; import { formatEquation } from '@/components/utils/formatEquation'; +import ThermodynamicsTable from '@/components/ui/ThermodynamicsTable'; /* ─── Helpers ────────────────────────────────────────────────── */ @@ -365,12 +366,19 @@ export default function CompoundDetailPage() { : []; const aliasesWithoutName = cpd.aliases?.filter((a) => !a.startsWith('Name:')) ?? []; - const pkaDisplay = cpd.pka?.[0]?.replace(/"/g, '') ?? null; - const pkbDisplay = cpd.pkb?.[0]?.replace(/"/g, '') ?? null; + const pkaValues = (Array.isArray(cpd.pka_value) ? cpd.pka_value : Array.isArray(cpd.pka) ? cpd.pka : []) + .map((v) => String(v).replace(/"/g, '')); + const pkbValues = (Array.isArray(cpd.pkb_value) ? cpd.pkb_value : Array.isArray(cpd.pkb) ? cpd.pkb : []) + .map((v) => String(v).replace(/"/g, '')); const deltaGDisplay = cpd.deltag === 10000000 ? 'unspecified' : String(cpd.deltag); const deltaGerrDisplay = cpd.deltagerr === 10000000 ? 'unspecified' : String(cpd.deltagerr); + const thermoRecords = cpd.thermodynamics ?? []; + const thermoLabel = cpd.n_sources_thermodynamics && cpd.n_sources_thermodynamics > 0 + ? `Thermodynamics (${cpd.n_sources_thermodynamics} sources)` + : 'Thermodynamics'; + return ( {/* ── Title ── */} @@ -419,19 +427,33 @@ export default function CompoundDetailPage() { {/* Properties */} - - - {deltaGDisplay === 'unspecified' ? 'N/A' : `${deltaGDisplay}${deltaGerrDisplay !== 'unspecified' ? ` ± ${deltaGerrDisplay}` : ''} kcal/mol`} - - - {pkaDisplay && ( + {thermoRecords.length > 0 ? ( + + + + ) : ( + + + {deltaGDisplay === 'unspecified' ? 'N/A' : `${deltaGDisplay}${deltaGerrDisplay !== 'unspecified' ? ` ± ${deltaGerrDisplay}` : ''} kcal/mol`} + + + )} + {pkaValues.length > 0 && ( - + + {pkaValues.map((v, i) => ( + + ))} + )} - {pkbDisplay && ( + {pkbValues.length > 0 && ( - + + {pkbValues.map((v, i) => ( + + ))} + )} diff --git a/app/(reference-data)/biochem/reactions/[id]/page.tsx b/app/(reference-data)/biochem/reactions/[id]/page.tsx index de7bddf0..b4fee4b8 100644 --- a/app/(reference-data)/biochem/reactions/[id]/page.tsx +++ b/app/(reference-data)/biochem/reactions/[id]/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMemo } from 'react'; import { useParams } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; import Box from '@mui/material/Box'; @@ -13,6 +14,9 @@ import Link from 'next/link'; import { getReactionById, EXTERNAL_DBS } from '@/lib/api/biochem'; import ChemicalEquation from '@/components/ui/ChemicalEquation'; import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation'; +import ThermodynamicsTable from '@/components/ui/ThermodynamicsTable'; +import AtomMappingSummary from '@/components/ui/AtomMappingSummary'; +import { parseAtomMappings } from '@/lib/utils/atomMapping'; function extractCompoundIds(equation: string): string[] { if (!equation) return []; @@ -308,6 +312,8 @@ export default function ReactionDetailPage() { enabled: !!id, }); + const atomPairs = useMemo(() => parseAtomMappings(rxn?.atom_mapping), [rxn?.atom_mapping]); + if (isLoading) { return ( @@ -335,6 +341,8 @@ export default function ReactionDetailPage() { const compoundIds = extractCompoundIds(rxn.equation || rxn.definition); + const thermoRecords = rxn.thermodynamics ?? []; + const dg = Number(rxn.deltag); const err = Number(rxn.deltagerr); const deltaGLabel = Number.isNaN(dg) @@ -381,11 +389,38 @@ export default function ReactionDetailPage() { - - - {deltaGLabel} - - + {thermoRecords.length > 0 ? ( + 0 + ? `Thermodynamics (${rxn.n_sources_thermodynamics} sources)` + : 'Thermodynamics' + } + > + + {typeof rxn.sources_agree_direction === 'boolean' && ( + + )} + + + + ) : ( + + + {deltaGLabel} + + + )} {ecNumbers.length ? ( @@ -454,6 +489,15 @@ export default function ReactionDetailPage() { )} + + {atomPairs.length > 0 && ( + + + + )} diff --git a/components/ui/AtomMappingSummary.tsx b/components/ui/AtomMappingSummary.tsx new file mode 100644 index 00000000..e673709a --- /dev/null +++ b/components/ui/AtomMappingSummary.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import Collapse from '@mui/material/Collapse'; +import Button from '@mui/material/Button'; +import NextLink from 'next/link'; +import { + parseAtomMappings, + groupAtomMappingsByCompound, + countAtomsPerElement, +} from '@/lib/utils/atomMapping'; + +export interface AtomMappingSummaryProps { + entries: readonly string[] | undefined; + confidence?: string; +} + +function confidenceColor(value: string): 'success' | 'warning' | 'default' { + if (value === 'clean') return 'success'; + if (value === 'salvaged') return 'warning'; + return 'default'; +} + +const compoundLinkStyle = { color: '#00838f', textDecoration: 'none', fontWeight: 600 }; + +export default function AtomMappingSummary({ entries, confidence }: AtomMappingSummaryProps) { + const pairs = useMemo(() => parseAtomMappings(entries), [entries]); + const [showAll, setShowAll] = useState(false); + + if (pairs.length === 0) return null; + + const groups = groupAtomMappingsByCompound(pairs); + const compoundIds = Array.from(groups.keys()); + const elementCounts = countAtomsPerElement(pairs); + + return ( + + + + {pairs.length} atom mappings across {compoundIds.length} compounds + + {typeof confidence === 'string' && confidence.length > 0 && ( + + )} + + + + {compoundIds.map((compoundId) => { + const counts = elementCounts.get(compoundId); + const countText = counts + ? Array.from(counts.entries()) + .map(([element, count]) => `${element} x${count}`) + .join(', ') + : ''; + return ( + + + {compoundId} + + + : {countText} + + + ); + })} + + + + + + + {pairs.map((pair, index) => ( + + {pair.raw} + + ))} + + + + + ); +} diff --git a/components/ui/ThermodynamicsTable.tsx b/components/ui/ThermodynamicsTable.tsx new file mode 100644 index 00000000..7365dac7 --- /dev/null +++ b/components/ui/ThermodynamicsTable.tsx @@ -0,0 +1,55 @@ +'use client'; + +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Typography from '@mui/material/Typography'; +import type { ThermodynamicsRecord } from '@/lib/api/biochem'; + +export interface ThermodynamicsTableProps { + records: ThermodynamicsRecord[]; + showOperator?: boolean; +} + +function displayValue(value: number | null | undefined): string { + return typeof value === 'number' ? String(value) : 'N/A'; +} + +export default function ThermodynamicsTable({ records, showOperator }: ThermodynamicsTableProps) { + if (!Array.isArray(records) || records.length === 0) return null; + + return ( + + + + Source + ΔG (kcal/mol) + Error + {showOperator && Operator} + + + + {records.map((record, index) => ( + + + {record.source_name} + + + {displayValue(record.energy)} + + + {displayValue(record.error)} + + {showOperator && ( + + {record.operator || '—'} + + )} + + ))} + +
+ ); +} diff --git a/tests/unit/components/AtomMappingSummary.test.tsx b/tests/unit/components/AtomMappingSummary.test.tsx new file mode 100644 index 00000000..84d8badc --- /dev/null +++ b/tests/unit/components/AtomMappingSummary.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import AtomMappingSummary from '@/components/ui/AtomMappingSummary'; + +const VALID_PAIRS = [ + 'cpd00001:O#1=cpd00009:O#2', + 'cpd00012:O#1=cpd00009:O#1', + 'cpd00012:O#2=cpd00009:O#2', + 'cpd00012:O#3=cpd00009:O#3', + 'cpd00012:O#4=cpd00009:O#3', +]; + +describe('AtomMappingSummary', () => { + it('renders nothing when entries is undefined', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when entries contains only malformed strings', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders the summary count and all compound ids for the five valid pairs', () => { + const { container } = render(); + + expect(container.textContent).toContain('5 atom mappings across 3 compounds'); + expect(container.textContent).toContain('cpd00001'); + expect(container.textContent).toContain('cpd00009'); + expect(container.textContent).toContain('cpd00012'); + }); + + it('renders a success chip for confidence "clean"', () => { + const { container } = render(); + expect(container.textContent).toContain('clean'); + }); + + it('renders a warning chip for confidence "salvaged"', () => { + const { container } = render(); + expect(container.textContent).toContain('salvaged'); + }); + + it('still renders a chip for an unrecognised confidence value', () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('mystery-value'); + }); + + it('hides the raw pair list until the toggle is clicked, then shows it', () => { + const { container, getByText } = render(); + + expect(container.textContent).not.toContain('cpd00001:O#1=cpd00009:O#2'); + + fireEvent.click(getByText('Show all mappings')); + + expect(container.textContent).toContain('cpd00001:O#1=cpd00009:O#2'); + expect(container.textContent).toContain('cpd00012:O#4=cpd00009:O#3'); + }); +}); diff --git a/tests/unit/components/ThermodynamicsTable.test.tsx b/tests/unit/components/ThermodynamicsTable.test.tsx new file mode 100644 index 00000000..0fa8703e --- /dev/null +++ b/tests/unit/components/ThermodynamicsTable.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import ThermodynamicsTable from '@/components/ui/ThermodynamicsTable'; +import type { ThermodynamicsRecord } from '@/lib/api/biochem'; + +describe('ThermodynamicsTable', () => { + it('renders nothing for an empty array', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders one body row per record, in the given order', () => { + const records: ThermodynamicsRecord[] = [ + { source_name: 'eQuilibrator', energy: -12.3, error: 0.5 }, + { source_name: 'Alberty', energy: -10.1, error: 1.2 }, + { source_name: 'Jankowski', energy: -8.4, error: 0.9 }, + ]; + const { container } = render(); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(3); + expect(rows[0].textContent).toContain('eQuilibrator'); + expect(rows[1].textContent).toContain('Alberty'); + expect(rows[2].textContent).toContain('Jankowski'); + }); + + it('renders N/A for null energy and null error', () => { + const records: ThermodynamicsRecord[] = [ + { source_name: 'eQuilibrator', energy: null, error: null }, + ]; + const { container } = render(); + + const row = container.querySelector('tbody tr'); + expect(row?.textContent).toContain('N/A'); + }); + + it('hides the Operator column when showOperator is false', () => { + const records: ThermodynamicsRecord[] = [ + { source_name: 'eQuilibrator', energy: -1, error: 0.1, operator: '=' }, + ]; + const { container } = render(); + + expect(container.textContent).not.toContain('Operator'); + }); + + it('shows the Operator column with its value when showOperator is true', () => { + const records: ThermodynamicsRecord[] = [ + { source_name: 'eQuilibrator', energy: -1, error: 0.1, operator: '=' }, + ]; + const { container } = render(); + + expect(container.textContent).toContain('Operator'); + const row = container.querySelector('tbody tr'); + expect(row?.textContent).toContain('='); + }); + + it('renders both rows when source_name is duplicated across records', () => { + const records: ThermodynamicsRecord[] = [ + { source_name: 'eQuilibrator', energy: -1, error: 0.1 }, + { source_name: 'eQuilibrator', energy: -2, error: 0.2 }, + ]; + const { container } = render(); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain('-1'); + expect(rows[1].textContent).toContain('-2'); + }); +}); From 717823e75abea6e2c4f566c5b920bed5e2032c21 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Wed, 12 Aug 2026 11:55:52 -0500 Subject: [PATCH 04/34] docs(changelog): note Solr 9 thermodynamics and atom mapping UI support --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60d0f1ff..7f870d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - TBD +### Added +- Compound and reaction detail pages now list every thermodynamics record returned by the upgraded Solr schema, one row per source with energy, error and (for reactions) direction operator +- Compound detail page now shows all pKa and pKb values instead of only the first +- Reaction detail page now shows an atom-mapping summary with per-compound element counts, a confidence indicator and an expandable raw list +- All of the above is feature-detected, so pages render exactly as before against the current production Solr + ### Known Issues - RAST MS FBA not working - PATRIC-only model submission From 6fcd75e6264b3e1728ce631312e8bca733b5c18e Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Wed, 19 Aug 2026 11:31:49 -0500 Subject: [PATCH 05/34] feat(biochem): visualize atom mappings as a reactant-to-product flow diagram Add summarizeAtomFlows(), which folds parsed atom-mapping pairs into one directed flow per (reactant compound -> product compound), counting distinct source atoms per element, and AtomFlowDiagram, an inline-SVG bipartite view of those flows with linked compound ids and per-element edge titles. Per-atom colouring is deliberately not attempted: the indices are InChI canonical per-element positions, and live data (rxn00001, stoichiometry (2) cpd00009) maps several distinct source atoms onto the same target index, so an atom-level correspondence is not derivable from this data alone. --- components/ui/AtomFlowDiagram.tsx | 76 +++++++++++++++++++ lib/utils/atomMapping.ts | 45 +++++++++++ .../unit/components/AtomFlowDiagram.test.tsx | 36 +++++++++ tests/unit/utils/atomMapping.test.ts | 61 +++++++++++++-- 4 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 components/ui/AtomFlowDiagram.tsx create mode 100644 tests/unit/components/AtomFlowDiagram.test.tsx diff --git a/components/ui/AtomFlowDiagram.tsx b/components/ui/AtomFlowDiagram.tsx new file mode 100644 index 00000000..859fee96 --- /dev/null +++ b/components/ui/AtomFlowDiagram.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useMemo } from 'react'; +import Link from 'next/link'; +import { summarizeAtomFlows, type AtomMappingPair } from '@/lib/utils/atomMapping'; + +export interface AtomFlowDiagramProps { + pairs: readonly AtomMappingPair[]; +} + +const LEFT_X = 120; +const RIGHT_X = 420; +const TOP_Y = 36; +const ROW_HEIGHT = 44; + +export default function AtomFlowDiagram({ pairs }: AtomFlowDiagramProps): React.ReactElement | null { + const flows = useMemo(() => summarizeAtomFlows(pairs), [pairs]); + + if (flows.length === 0) return null; + + const fromIds = Array.from(new Set(flows.map((flow) => flow.from))); + const toIds = Array.from(new Set(flows.map((flow) => flow.to))); + const fromY = new Map(fromIds.map((id, index) => [id, TOP_Y + index * ROW_HEIGHT])); + const toY = new Map(toIds.map((id, index) => [id, TOP_Y + index * ROW_HEIGHT])); + const height = TOP_Y * 2 + (Math.max(fromIds.length, toIds.length) - 1) * ROW_HEIGHT; + const largestTotal = Math.max(...flows.map((flow) => flow.total)); + const strokeWidth = (total: number) => + largestTotal === 0 ? 1.5 : 1.5 + ((total / largestTotal) * 6.5); + + return ( +
+ + {flows.map((flow) => { + const breakdown = Array.from(flow.byElement.entries()) + .map(([element, count]) => `${element} ${count}`) + .join(', '); + return ( + ${flow.to}`} + x1={LEFT_X} + y1={fromY.get(flow.from)} + x2={RIGHT_X} + y2={toY.get(flow.to)} + stroke="#00838f" + strokeOpacity="0.65" + strokeWidth={strokeWidth(flow.total)} + > + {`${flow.from} to ${flow.to}: ${flow.total} atoms (${breakdown})`} + + ); + })} + {fromIds.map((id) => ( + + + {id} + + + ))} + {toIds.map((id) => ( + + + {id} + + + ))} + + Counts are mapped atoms per compound pair; individual atom positions are not shown. +
+ ); +} diff --git a/lib/utils/atomMapping.ts b/lib/utils/atomMapping.ts index 41695f63..d6d13fc6 100644 --- a/lib/utils/atomMapping.ts +++ b/lib/utils/atomMapping.ts @@ -168,3 +168,48 @@ export function countAtomsPerElement( return counts; } + +export interface AtomFlow { + from: string; + to: string; + total: number; + byElement: ReadonlyMap; +} + +/** Summarize distinct source atoms as directed compound-to-compound flows. */ +export function summarizeAtomFlows(pairs: readonly AtomMappingPair[]): AtomFlow[] { + const flows = new Map< + string, + { from: string; to: string; total: number; byElement: Map; seen: Set } + >(); + + for (const pair of pairs) { + const { left, right } = pair; + const key = `${left.compoundId}>${right.compoundId}`; + let flow = flows.get(key); + if (!flow) { + flow = { + from: left.compoundId, + to: right.compoundId, + total: 0, + byElement: new Map(), + seen: new Set(), + }; + flows.set(key, flow); + } + + const sourceAtom = `${left.element}#${left.index}`; + if (!flow.seen.has(sourceAtom)) { + flow.seen.add(sourceAtom); + flow.total += 1; + flow.byElement.set(left.element, (flow.byElement.get(left.element) ?? 0) + 1); + } + } + + return Array.from(flows.values(), ({ from, to, total, byElement }) => ({ + from, + to, + total, + byElement, + })); +} diff --git a/tests/unit/components/AtomFlowDiagram.test.tsx b/tests/unit/components/AtomFlowDiagram.test.tsx new file mode 100644 index 00000000..aed410d2 --- /dev/null +++ b/tests/unit/components/AtomFlowDiagram.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import AtomFlowDiagram from '@/components/ui/AtomFlowDiagram'; +import { parseAtomMappings } from '@/lib/utils/atomMapping'; + +const RXN00001_PAIRS = parseAtomMappings([ + 'rxn00001 cpd00001:O#1=cpd00009:O#2', + 'rxn00001 cpd00012:O#1=cpd00009:O#1', + 'rxn00001 cpd00012:O#2=cpd00009:O#2', + 'rxn00001 cpd00012:O#3=cpd00009:O#3', + 'rxn00001 cpd00012:O#4=cpd00009:O#3', + 'rxn00001 cpd00012:O#5=cpd00009:O#1', + 'rxn00001 cpd00012:O#6=cpd00009:O#4', + 'rxn00001 cpd00012:O#7=cpd00009:O#4', + 'rxn00001 cpd00012:P#1=cpd00009:P#1', + 'rxn00001 cpd00012:P#2=cpd00009:P#1', +]); + +describe('AtomFlowDiagram', () => { + it('renders nothing for an empty pair list', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders compound ids, totals, links, and element breakdowns', () => { + const { container, getByText } = render(); + + expect(container.textContent).toContain('cpd00001'); + expect(container.textContent).toContain('cpd00009'); + expect(container.querySelector('title')?.textContent).toContain('1 atoms (O 1)'); + expect(container.querySelectorAll('title')[1]?.textContent).toContain('9 atoms (O 7, P 2)'); + expect(getByText('cpd00009').closest('a')?.getAttribute('href')).toBe( + '/biochem/compounds/cpd00009', + ); + }); +}); diff --git a/tests/unit/utils/atomMapping.test.ts b/tests/unit/utils/atomMapping.test.ts index 361a4e4e..d72ea39c 100644 --- a/tests/unit/utils/atomMapping.test.ts +++ b/tests/unit/utils/atomMapping.test.ts @@ -4,6 +4,7 @@ import { groupAtomMappingsByCompound, parseAtomMappingEntry, parseAtomMappings, + summarizeAtomFlows, } from '@/lib/utils/atomMapping'; // Five real upstream lines (rxn00001), the last two sharing cpd00009:O#3. @@ -13,6 +14,11 @@ const UPSTREAM_LINES = [ 'rxn00001 cpd00012:O#2=cpd00009:O#2', 'rxn00001 cpd00012:O#3=cpd00009:O#3', 'rxn00001 cpd00012:O#4=cpd00009:O#3', + 'rxn00001 cpd00012:O#5=cpd00009:O#1', + 'rxn00001 cpd00012:O#6=cpd00009:O#4', + 'rxn00001 cpd00012:O#7=cpd00009:O#4', + 'rxn00001 cpd00012:P#1=cpd00009:P#1', + 'rxn00001 cpd00012:P#2=cpd00009:P#1', ]; describe('parseAtomMappingEntry', () => { @@ -92,9 +98,9 @@ describe('parseAtomMappings', () => { expect(pairs[1].raw).toBe('cpd00012:O#1=cpd00009:O#1'); }); - it('parses all five real upstream lines in order', () => { + it('parses all real upstream lines in order', () => { const pairs = parseAtomMappings(UPSTREAM_LINES); - expect(pairs).toHaveLength(5); + expect(pairs).toHaveLength(10); expect(pairs.map((p) => p.raw)).toEqual( UPSTREAM_LINES.map((l) => l.replace(/^rxn\d+\s+/, '')), ); @@ -107,8 +113,8 @@ describe('groupAtomMappingsByCompound', () => { const groups = groupAtomMappingsByCompound(pairs); expect(groups.get('cpd00001')).toHaveLength(1); - expect(groups.get('cpd00009')).toHaveLength(5); - expect(groups.get('cpd00012')).toHaveLength(4); + expect(groups.get('cpd00009')).toHaveLength(10); + expect(groups.get('cpd00012')).toHaveLength(9); }); it('adds a self-pair to its shared compound bucket exactly once', () => { @@ -130,15 +136,54 @@ describe('groupAtomMappingsByCompound', () => { }); }); +describe('summarizeAtomFlows', () => { + it('summarizes the real rxn00001 mappings by directed compound pair', () => { + const flows = summarizeAtomFlows(parseAtomMappings(UPSTREAM_LINES)); + + expect(flows).toHaveLength(2); + expect(flows[0]).toMatchObject({ from: 'cpd00001', to: 'cpd00009', total: 1 }); + expect(Array.from(flows[0].byElement)).toEqual([['O', 1]]); + expect(flows[1]).toMatchObject({ from: 'cpd00012', to: 'cpd00009', total: 9 }); + expect(Array.from(flows[1].byElement)).toEqual([['O', 7], ['P', 2]]); + }); + + it('returns an empty array for empty input', () => { + expect(summarizeAtomFlows([])).toEqual([]); + }); + + it('keeps self-pairs and counts a repeated source atom once', () => { + const flows = summarizeAtomFlows(parseAtomMappings([ + 'cpd00001:O#1=cpd00001:O#2', + 'cpd00001:O#1=cpd00001:O#3', + ])); + + expect(flows[0]).toMatchObject({ from: 'cpd00001', to: 'cpd00001', total: 1 }); + }); + + it('preserves first appearance order for flow groups', () => { + const flows = summarizeAtomFlows(parseAtomMappings([ + 'cpd00002:O#1=cpd00003:O#1', + 'cpd00001:O#1=cpd00003:O#2', + 'cpd00002:P#1=cpd00003:P#1', + ])); + + expect(flows.map(({ from, to }) => `${from}>${to}`)).toEqual([ + 'cpd00002>cpd00003', + 'cpd00001>cpd00003', + ]); + }); +}); + describe('countAtomsPerElement', () => { it('counts distinct atom indices per element per compound', () => { const pairs = parseAtomMappings(UPSTREAM_LINES); const counts = countAtomsPerElement(pairs); - // cpd00009:O appears at indices 2, 1, 2, 3, 3 across the five lines -> 3 distinct. - expect(counts.get('cpd00009')?.get('O')).toBe(3); - // cpd00012:O appears at indices 1, 2, 3, 4 -> 4 distinct. - expect(counts.get('cpd00012')?.get('O')).toBe(4); + // cpd00009:O appears at indices 2, 1, 2, 3, 3, 1, 4, 4 -> 4 distinct. + expect(counts.get('cpd00009')?.get('O')).toBe(4); + // cpd00012:O appears at indices 1 through 7 -> 7 distinct. + expect(counts.get('cpd00012')?.get('O')).toBe(7); + expect(counts.get('cpd00012')?.get('P')).toBe(2); // cpd00001:O appears once. expect(counts.get('cpd00001')?.get('O')).toBe(1); }); From 0ac6f480ac44afe5c2d6d2200cd4293746b09c34 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Wed, 19 Aug 2026 11:31:55 -0500 Subject: [PATCH 06/34] feat(biochem): report three-state reaction direction agreement from source operators Derive the direction-agreement label from the per-source thermodynamics direction operators instead of the single server-supplied sources_agree_direction flag, which cannot express three states. - all operators identical -> Sources agree on direction - only one angle-bracket direction, optionally mixed with '=' -> Sources could agree on direction - both '>' and '<' present -> Sources disagree on direction The server boolean remains a fallback when no source reports an operator. The reaction detail page also renders the new AtomFlowDiagram above the existing atom-mapping summary. --- .../biochem/reactions/[id]/page.tsx | 41 +++++++--- lib/utils/reactionDirection.ts | 55 +++++++++++++ .../components/ThermodynamicsTable.test.tsx | 78 ++++++++++++++++++- tests/unit/utils/reactionDirection.test.ts | 68 ++++++++++++++++ 4 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 lib/utils/reactionDirection.ts create mode 100644 tests/unit/utils/reactionDirection.test.ts diff --git a/app/(reference-data)/biochem/reactions/[id]/page.tsx b/app/(reference-data)/biochem/reactions/[id]/page.tsx index b4fee4b8..30906447 100644 --- a/app/(reference-data)/biochem/reactions/[id]/page.tsx +++ b/app/(reference-data)/biochem/reactions/[id]/page.tsx @@ -16,7 +16,13 @@ import ChemicalEquation from '@/components/ui/ChemicalEquation'; import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation'; import ThermodynamicsTable from '@/components/ui/ThermodynamicsTable'; import AtomMappingSummary from '@/components/ui/AtomMappingSummary'; +import AtomFlowDiagram from '@/components/ui/AtomFlowDiagram'; import { parseAtomMappings } from '@/lib/utils/atomMapping'; +import { + directionAgreementFromRecords, + DIRECTION_AGREEMENT_COLOR, + DIRECTION_AGREEMENT_LABEL, +} from '@/lib/utils/reactionDirection'; function extractCompoundIds(equation: string): string[] { if (!equation) return []; @@ -342,6 +348,7 @@ export default function ReactionDetailPage() { const compoundIds = extractCompoundIds(rxn.equation || rxn.definition); const thermoRecords = rxn.thermodynamics ?? []; + const agreement = directionAgreementFromRecords(thermoRecords); const dg = Number(rxn.deltag); const err = Number(rxn.deltagerr); @@ -399,17 +406,26 @@ export default function ReactionDetailPage() { } > - {typeof rxn.sources_agree_direction === 'boolean' && ( + {agreement !== null ? ( + ) : ( + typeof rxn.sources_agree_direction === 'boolean' && ( + + ) )} @@ -492,10 +508,13 @@ export default function ReactionDetailPage() { {atomPairs.length > 0 && ( - + + + + )} diff --git a/lib/utils/reactionDirection.ts b/lib/utils/reactionDirection.ts new file mode 100644 index 00000000..ac4706ec --- /dev/null +++ b/lib/utils/reactionDirection.ts @@ -0,0 +1,55 @@ +/** + * Classifies direction agreement using these rules: + * RULE 1: if distinct.size === 1 return 'agree'. + * RULE 3: else if some distinct value includes '>' AND some distinct value includes '<', return 'disagree'. + * RULE 2: otherwise return 'could-agree'. + * + * The `operator` values observed in the live Solr index are the single + * characters '=', '>', '<'. + */ + +export type DirectionAgreement = 'agree' | 'could-agree' | 'disagree'; + +export function classifyDirectionAgreement( + directions: readonly (string | undefined | null)[], +): DirectionAgreement | null { + const values = directions + .filter((direction): direction is string => typeof direction === 'string') + .map((direction) => direction.trim()) + .filter(Boolean); + + if (values.length === 0) return null; + + const distinct = new Set(values); + if (distinct.size === 1) return 'agree'; + + if ( + values.some((v) => v.includes('>')) && + values.some((v) => v.includes('<')) + ) { + return 'disagree'; + } + + return 'could-agree'; +} + +export function directionAgreementFromRecords( + records: readonly { operator?: string }[] | undefined | null, +): DirectionAgreement | null { + return classifyDirectionAgreement((records ?? []).map((r) => r.operator)); +} + +export const DIRECTION_AGREEMENT_LABEL: Record = { + agree: 'Sources agree on direction', + 'could-agree': 'Sources could agree on direction', + disagree: 'Sources disagree on direction', +}; + +export const DIRECTION_AGREEMENT_COLOR: Record< + DirectionAgreement, + 'success' | 'info' | 'warning' +> = { + agree: 'success', + 'could-agree': 'info', + disagree: 'warning', +}; diff --git a/tests/unit/components/ThermodynamicsTable.test.tsx b/tests/unit/components/ThermodynamicsTable.test.tsx index 0fa8703e..a02638b2 100644 --- a/tests/unit/components/ThermodynamicsTable.test.tsx +++ b/tests/unit/components/ThermodynamicsTable.test.tsx @@ -1,7 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import Chip from '@mui/material/Chip'; import ThermodynamicsTable from '@/components/ui/ThermodynamicsTable'; import type { ThermodynamicsRecord } from '@/lib/api/biochem'; +import { + directionAgreementFromRecords, + DIRECTION_AGREEMENT_COLOR, + DIRECTION_AGREEMENT_LABEL, +} from '@/lib/utils/reactionDirection'; describe('ThermodynamicsTable', () => { it('renders nothing for an empty array', () => { @@ -67,3 +73,73 @@ describe('ThermodynamicsTable', () => { expect(rows[1].textContent).toContain('-2'); }); }); + +describe('direction agreement labels', () => { + it('shows Seaver rule 1 when all sources use equals operators', () => { + const records: ThermodynamicsRecord[] = [ + { source_name: 'eQuilibrator', energy: -12.3, error: 0.5, operator: '=' }, + { source_name: 'Alberty', energy: -10.1, error: 1.2, operator: '=' }, + { source_name: 'Jankowski', energy: -8.4, error: 0.9, operator: '=' }, + ]; + const agreement = directionAgreementFromRecords(records); + + render( + <> + + + , + ); + + expect(screen.getByText('Sources agree on direction')).toBeTruthy(); + records.forEach(({ source_name }) => expect(screen.getByText(source_name)).toBeTruthy()); + }); + + it('shows Seaver rule 2 when greater-than and equals operators are present', () => { + const records: ThermodynamicsRecord[] = [ + { source_name: 'eQuilibrator', energy: -12.3, error: 0.5, operator: '>' }, + { source_name: 'Alberty', energy: -10.1, error: 1.2, operator: '=' }, + ]; + const agreement = directionAgreementFromRecords(records); + + render( + <> + + + , + ); + + expect(screen.getByText('Sources could agree on direction')).toBeTruthy(); + records.forEach(({ source_name }) => expect(screen.getByText(source_name)).toBeTruthy()); + }); + + it('shows Seaver rule 3 when greater-than, less-than, and equals operators are present', () => { + const records: ThermodynamicsRecord[] = [ + { source_name: 'eQuilibrator', energy: -12.3, error: 0.5, operator: '>' }, + { source_name: 'Alberty', energy: -10.1, error: 1.2, operator: '<' }, + { source_name: 'Jankowski', energy: -8.4, error: 0.9, operator: '=' }, + ]; + const agreement = directionAgreementFromRecords(records); + + render( + <> + + + , + ); + + expect(screen.getByText('Sources disagree on direction')).toBeTruthy(); + records.forEach(({ source_name }) => expect(screen.getByText(source_name)).toBeTruthy()); + }); +}); diff --git a/tests/unit/utils/reactionDirection.test.ts b/tests/unit/utils/reactionDirection.test.ts new file mode 100644 index 00000000..a252e5fa --- /dev/null +++ b/tests/unit/utils/reactionDirection.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + classifyDirectionAgreement, + directionAgreementFromRecords, + DIRECTION_AGREEMENT_LABEL, +} from '@/lib/utils/reactionDirection'; + +describe('classifyDirectionAgreement', () => { + it.each([ + [['='], 'agree'], + [['>'], 'agree'], + [['<'], 'agree'], + [['=', '='], 'agree'], + [['>', '>', '>'], 'agree'], + [['=', '>'], 'could-agree'], + [['>', '='], 'could-agree'], + [['=', '<'], 'could-agree'], + [['=', '<', '='], 'could-agree'], + [['>', '<'], 'disagree'], + [['<', '>'], 'disagree'], + [['=', '>', '<'], 'disagree'], + [[], null], + [[undefined, null, '', ' '], null], + [['=', undefined, '>'], 'could-agree'], + [['>', '>'], 'agree'], + [['<', '=', '<'], 'could-agree'], + [['=', '='], 'agree'], + [['<', '='], 'could-agree'], + [['=', '>', '<'], 'disagree'], + ] as const)('classifies %j as %s', (directions, expected) => { + expect(classifyDirectionAgreement(directions)).toBe(expected); + }); + + it("classifies a set containing both '>' and '<' as disagree when '=' is also present", () => { + expect(classifyDirectionAgreement(['>', '<', '='])).toBe('disagree'); + }); + + it('uses the literal rule for multi-character operators pending real multi-character data', () => { + expect(classifyDirectionAgreement(['<=>'])).toBe('agree'); + expect(classifyDirectionAgreement(['<=>', '='])).toBe('disagree'); + }); +}); + +describe('directionAgreementFromRecords', () => { + it('adapts thermodynamics records', () => { + const disagreeingRecords = [ + { source_name: 'a', operator: '>' }, + { source_name: 'b', operator: '<' }, + ]; + const recordsWithoutOperator: Array<{ + source_name: string; + operator?: string; + }> = [{ source_name: 'a' }]; + + expect(directionAgreementFromRecords(disagreeingRecords)).toBe('disagree'); + expect(directionAgreementFromRecords(recordsWithoutOperator)).toBeNull(); + }); +}); + +describe('DIRECTION_AGREEMENT_LABEL', () => { + it('uses the exact direction labels', () => { + expect(DIRECTION_AGREEMENT_LABEL).toEqual({ + agree: 'Sources agree on direction', + 'could-agree': 'Sources could agree on direction', + disagree: 'Sources disagree on direction', + }); + }); +}); From 104af9072ff0be02eabca20274b2be546bb741cd Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Wed, 19 Aug 2026 11:31:55 -0500 Subject: [PATCH 07/34] docs(changelog): record three-state direction wording and the atom-flow diagram --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f870d46..be72fa2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compound and reaction detail pages now list every thermodynamics record returned by the upgraded Solr schema, one row per source with energy, error and (for reactions) direction operator - Compound detail page now shows all pKa and pKb values instead of only the first - Reaction detail page now shows an atom-mapping summary with per-compound element counts, a confidence indicator and an expandable raw list +- Reaction detail page now visualises atom mappings as a reactant-to-product atom-flow diagram, with one edge per compound pair scaled by the number of mapped atoms and a per-element breakdown - All of the above is feature-detected, so pages render exactly as before against the current production Solr +### Changed +- Reaction thermodynamics direction agreement is now derived from the per-source direction operators rather than a single server flag, and reports three states: "Sources agree on direction" (all operators identical), "Sources could agree on direction" (only one angle-bracket direction, optionally mixed with `=`) and "Sources disagree on direction" (both `>` and `<` present) + ### Known Issues - RAST MS FBA not working - PATRIC-only model submission From e1ccbd97b2bc9c712f4f05224b37c324259a8480 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 11:09:22 -0500 Subject: [PATCH 08/34] feat(biochem): parse symmetry-grouped atom mappings --- lib/utils/atomMapping.ts | 129 +++++++++++++++++++--- tests/unit/utils/atomMapping.test.ts | 153 ++++++++++++++++++++++++++- 2 files changed, 262 insertions(+), 20 deletions(-) diff --git a/lib/utils/atomMapping.ts b/lib/utils/atomMapping.ts index d6d13fc6..681f49f9 100644 --- a/lib/utils/atomMapping.ts +++ b/lib/utils/atomMapping.ts @@ -2,13 +2,13 @@ * Parse the Solr-9 reaction `atom_mapping` field into typed, grouped atom-pair * records. * - * Wire format: the field is an array of strings, ONE ATOM PAIR PER ELEMENT, - * shaped as: + * Wire format: the field is an array of strings, shaped as: * * cpdAAAAA:E#N=cpdBBBBB:E#M + * cpdAAAAA:(E#N;E#N)=cpdBBBBB:E#M * - * where `E` is an element symbol (e.g. `O`, `H`, `Mg`) and `N`/`M` are - * 1-based atom indices. The raw upstream `.txt` export prefixes each line + * where a parenthesized side is a symmetry group and `E` is an element symbol + * (e.g. `O`, `H`, `Mg`). `N`/`M` are 1-based atom indices. The raw upstream `.txt` export prefixes each line * with a reaction id token (`rxn00001 cpd00001:O#1=cpd00009:O#2`); the Solr * field itself may or may not carry that prefix, so both forms are accepted. * @@ -34,26 +34,114 @@ export interface AtomRef { export interface AtomMappingPair { left: AtomRef; right: AtomRef; + leftAtoms: readonly AtomRef[]; + rightAtoms: readonly AtomRef[]; + hasSymmetryGroup: boolean; raw: string; } +export interface NormalizedAtomMapping { + entries: string[]; + confidence?: string; + hasSymmetryGroups: boolean; + source: 'atom_mapping_data' | 'atom_mapping' | 'none'; +} + +export interface AtomMappingSource { + atom_mapping_data?: unknown; + atom_mapping?: unknown; + atom_mapping_confidence?: unknown; + atom_mapping_has_symmetry_groups?: unknown; +} + const LEADING_REACTION_ID = /^rxn\d+\s+/; const ATOM_REF = /^([A-Za-z][A-Za-z0-9]*\d+):([A-Za-z][a-z]?)#(\d+)$/; +const COMPOUND_ID = /^[A-Za-z][A-Za-z0-9]*\d+$/; +const SINGLE_ATOM_REF = /^([A-Za-z][a-z]?)#(\d+)$/; + +function toStringArray(value: unknown): string[] { + const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []; + return values + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function normalizeAtomMapping( + doc: AtomMappingSource | null | undefined, +): NormalizedAtomMapping { + const dataEntries = toStringArray(doc?.atom_mapping_data); + const legacyEntries = toStringArray(doc?.atom_mapping); + const entries = dataEntries.length > 0 ? dataEntries : legacyEntries; + const source = dataEntries.length > 0 + ? 'atom_mapping_data' + : legacyEntries.length > 0 + ? 'atom_mapping' + : 'none'; + const confidence = typeof doc?.atom_mapping_confidence === 'string' + ? doc.atom_mapping_confidence.trim() || undefined + : undefined; + const symmetryFlag = doc?.atom_mapping_has_symmetry_groups; + const hasSymmetryGroups = typeof symmetryFlag === 'boolean' + ? symmetryFlag + : typeof symmetryFlag === 'string' && /^(true|false)$/i.test(symmetryFlag) + ? symmetryFlag.toLowerCase() === 'true' + : entries.some((entry) => entry.includes('(')); + + return { entries, confidence, hasSymmetryGroups, source }; +} + +function parseAtomSpec(compoundId: string, spec: string): AtomRef[] | null { + const parseMember = (member: string): AtomRef | null => { + const match = SINGLE_ATOM_REF.exec(member); + if (!match) return null; + + const [, element, indexText] = match; + const index = Number.parseInt(indexText, 10); + if (!Number.isFinite(index) || index <= 0) return null; + + return { compoundId, element, index }; + }; + + if (spec.startsWith('(')) { + if (!spec.endsWith(')')) return null; + + const members = spec.slice(1, -1).split(';').map((member) => member.trim()); + if (members.length === 0 || members.some((member) => !member)) return null; + + const atoms = members.map(parseMember); + return atoms.every((atom): atom is AtomRef => atom !== null) ? atoms : null; + } + + if (spec.includes('(') || spec.includes(')') || spec.includes(';')) return null; + + const atom = parseMember(spec); + return atom ? [atom] : null; +} /** * Parse one side of an atom-mapping entry, e.g. `cpd00001:O#1`. * Returns `null` for anything that does not match the expected shape, * including a zero or non-numeric index. */ -function parseAtomRef(side: string): AtomRef | null { - const match = ATOM_REF.exec(side); - if (!match) return null; +function parseAtomRef(side: string): AtomRef[] | null { + const separator = side.indexOf(':'); + if (separator === -1) return null; + + const compoundId = side.slice(0, separator); + if (!COMPOUND_ID.test(compoundId)) return null; + + const spec = side.slice(separator + 1); + if (!spec.includes('(') && !spec.includes(')') && !spec.includes(';') && !ATOM_REF.test(side)) { + return null; + } - const [, compoundId, element, indexText] = match; - const index = Number.parseInt(indexText, 10); - if (!Number.isFinite(index) || index <= 0) return null; + return parseAtomSpec(compoundId, spec); +} - return { compoundId, element, index }; +/** Format a singleton atom or symmetry group for display. */ +export function formatAtomGroup(atoms: readonly AtomRef[]): string { + return atoms.map(({ element, index }) => `${element}#${index}`).join(', '); } /** @@ -72,11 +160,18 @@ export function parseAtomMappingEntry(entry: string): AtomMappingPair | null { const parts = raw.split('='); if (parts.length !== 2) return null; - const left = parseAtomRef(parts[0]); - const right = parseAtomRef(parts[1]); - if (!left || !right) return null; + const leftAtoms = parseAtomRef(parts[0]); + const rightAtoms = parseAtomRef(parts[1]); + if (!leftAtoms || !rightAtoms) return null; - return { left, right, raw }; + return { + left: leftAtoms[0], + right: rightAtoms[0], + leftAtoms, + rightAtoms, + hasSymmetryGroup: leftAtoms.length > 1 || rightAtoms.length > 1, + raw, + }; } /** @@ -153,8 +248,8 @@ export function countAtomsPerElement( }; for (const pair of pairs) { - record(pair.left); - record(pair.right); + for (const atom of pair.leftAtoms) record(atom); + for (const atom of pair.rightAtoms) record(atom); } const counts = new Map>(); diff --git a/tests/unit/utils/atomMapping.test.ts b/tests/unit/utils/atomMapping.test.ts index d72ea39c..3d77b70c 100644 --- a/tests/unit/utils/atomMapping.test.ts +++ b/tests/unit/utils/atomMapping.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { countAtomsPerElement, + formatAtomGroup, groupAtomMappingsByCompound, + normalizeAtomMapping, parseAtomMappingEntry, parseAtomMappings, summarizeAtomFlows, @@ -21,12 +23,19 @@ const UPSTREAM_LINES = [ 'rxn00001 cpd00012:P#2=cpd00009:P#1', ]; +const SAM_ENTRIES = [ + 'cpd00001:O#1=cpd00009:(O#1;O#2;O#3;O#4)', + 'cpd00012:(O#1;O#2;O#3;O#4;O#5;O#6)=cpd00009:(O#1;O#2;O#3;O#4)', + 'cpd00012:(P#1;P#2)=cpd00009:P#1', + 'cpd00012:O#7=cpd00009:(O#1;O#2;O#3;O#4)', +]; + describe('parseAtomMappingEntry', () => { it('parses each real upstream line with its leading reaction-id token', () => { const parsed = UPSTREAM_LINES.map(parseAtomMappingEntry); expect(parsed.every((p) => p !== null)).toBe(true); - expect(parsed[0]).toEqual({ + expect(parsed[0]).toMatchObject({ left: { compoundId: 'cpd00001', element: 'O', index: 1 }, right: { compoundId: 'cpd00009', element: 'O', index: 2 }, raw: 'cpd00001:O#1=cpd00009:O#2', @@ -39,7 +48,7 @@ describe('parseAtomMappingEntry', () => { ); const parsed = stripped.map(parseAtomMappingEntry); expect(parsed.every((p) => p !== null)).toBe(true); - expect(parsed[4]).toEqual({ + expect(parsed[4]).toMatchObject({ left: { compoundId: 'cpd00012', element: 'O', index: 4 }, right: { compoundId: 'cpd00009', element: 'O', index: 3 }, raw: 'cpd00012:O#4=cpd00009:O#3', @@ -48,7 +57,7 @@ describe('parseAtomMappingEntry', () => { it('parses a two-character element symbol (Mg)', () => { const pair = parseAtomMappingEntry('cpd00254:Mg#1=cpd00099:Mg#1'); - expect(pair).toEqual({ + expect(pair).toMatchObject({ left: { compoundId: 'cpd00254', element: 'Mg', index: 1 }, right: { compoundId: 'cpd00099', element: 'Mg', index: 1 }, raw: 'cpd00254:Mg#1=cpd00099:Mg#1', @@ -61,6 +70,56 @@ describe('parseAtomMappingEntry', () => { expect(pair?.right.compoundId).toBe('cpd00001'); }); + it('parses symmetry groups and uses their first members as representatives', () => { + const pair = parseAtomMappingEntry( + 'cpd00001:(O#1; H#2;O#3)=cpd00009:(O#4;O#5)', + ); + + expect(pair?.left).toEqual({ compoundId: 'cpd00001', element: 'O', index: 1 }); + expect(pair?.right).toEqual({ compoundId: 'cpd00009', element: 'O', index: 4 }); + expect(pair?.leftAtoms).toEqual([ + { compoundId: 'cpd00001', element: 'O', index: 1 }, + { compoundId: 'cpd00001', element: 'H', index: 2 }, + { compoundId: 'cpd00001', element: 'O', index: 3 }, + ]); + expect(pair?.rightAtoms).toEqual([ + { compoundId: 'cpd00009', element: 'O', index: 4 }, + { compoundId: 'cpd00009', element: 'O', index: 5 }, + ]); + expect(pair?.hasSymmetryGroup).toBe(true); + }); + + it('parses group members with their own element symbols', () => { + const pair = parseAtomMappingEntry('cpd00012:(O#1;P#2)=cpd00009:O#1'); + expect(pair?.leftAtoms).toEqual([ + { compoundId: 'cpd00012', element: 'O', index: 1 }, + { compoundId: 'cpd00012', element: 'P', index: 2 }, + ]); + }); + + it('keeps legacy singleton mappings compatible with group consumers', () => { + const pair = parseAtomMappingEntry('cpd00001:O#1=cpd00009:O#2'); + expect(pair?.left).toEqual({ compoundId: 'cpd00001', element: 'O', index: 1 }); + expect(pair?.right).toEqual({ compoundId: 'cpd00009', element: 'O', index: 2 }); + expect(pair?.leftAtoms).toHaveLength(1); + expect(pair?.rightAtoms).toHaveLength(1); + expect(pair?.hasSymmetryGroup).toBe(false); + }); + + it('rejects malformed symmetry groups', () => { + for (const entry of [ + 'cpd00001:()=cpd00009:O#1', + 'cpd00001:(O#1;)=cpd00009:O#1', + 'cpd00001:(;O#1)=cpd00009:O#1', + 'cpd00001:(O#1=cpd00009:O#1', + 'cpd00001:O#1)=cpd00009:O#1', + 'cpd00001:(Oxx#1)=cpd00009:O#1', + 'cpd00001:(O#0)=cpd00009:O#1', + ]) { + expect(parseAtomMappingEntry(entry)).toBeNull(); + } + }); + it('returns null instead of throwing for malformed or missing input', () => { expect(parseAtomMappingEntry('')).toBeNull(); expect(parseAtomMappingEntry(' ')).toBeNull(); @@ -105,6 +164,18 @@ describe('parseAtomMappings', () => { UPSTREAM_LINES.map((l) => l.replace(/^rxn\d+\s+/, '')), ); }); + + it('parses all real Sam entries without dropping any', () => { + const pairs = parseAtomMappings(SAM_ENTRIES); + expect(pairs).toHaveLength(4); + expect(pairs.map((pair) => pair.raw)).toEqual(SAM_ENTRIES); + }); + + it('parses reaction-prefixed Sam entries identically', () => { + const pairs = parseAtomMappings(SAM_ENTRIES); + const prefixedPairs = parseAtomMappings(SAM_ENTRIES.map((entry) => `rxn00001 ${entry}`)); + expect(prefixedPairs).toEqual(pairs); + }); }); describe('groupAtomMappingsByCompound', () => { @@ -172,6 +243,13 @@ describe('summarizeAtomFlows', () => { 'cpd00001>cpd00003', ]); }); + + it('counts each symmetry group as one mapping event', () => { + const flows = summarizeAtomFlows(parseAtomMappings(SAM_ENTRIES)); + expect(flows).toHaveLength(2); + expect(flows[0]).toMatchObject({ from: 'cpd00001', to: 'cpd00009', total: 1 }); + expect(flows[1]).toMatchObject({ from: 'cpd00012', to: 'cpd00009', total: 3 }); + }); }); describe('countAtomsPerElement', () => { @@ -197,7 +275,76 @@ describe('countAtomsPerElement', () => { expect(counts.get('cpd00009')?.get('O')).toBe(1); }); + it('counts the union of all symmetry-group members', () => { + const counts = countAtomsPerElement(parseAtomMappings(SAM_ENTRIES)); + + expect(counts.get('cpd00009')?.get('O')).toBe(4); + expect(counts.get('cpd00009')?.get('P')).toBe(1); + expect(counts.get('cpd00012')?.get('O')).toBe(7); + expect(counts.get('cpd00012')?.get('P')).toBe(2); + expect(counts.get('cpd00001')?.get('O')).toBe(1); + }); + it('returns an empty map for an empty pair list', () => { expect(countAtomsPerElement([]).size).toBe(0); }); }); + +describe('formatAtomGroup', () => { + it('formats singleton atoms and symmetry groups', () => { + const pair = parseAtomMappingEntry('cpd00001:(O#1;O#2;O#3)=cpd00009:O#4'); + expect(formatAtomGroup(pair!.leftAtoms)).toBe('O#1, O#2, O#3'); + expect(formatAtomGroup(pair!.rightAtoms)).toBe('O#4'); + }); +}); + +describe('normalizeAtomMapping', () => { + it('prefers usable live Solr mappings and normalizes their metadata', () => { + expect(normalizeAtomMapping({ + atom_mapping_data: [' cpd00001:O#1=cpd00009:O#2 ', '', 42], + atom_mapping: ['cpd00012:O#1=cpd00009:O#1'], + atom_mapping_confidence: ' high ', + atom_mapping_has_symmetry_groups: 'TRUE', + })).toEqual({ + entries: ['cpd00001:O#1=cpd00009:O#2'], + confidence: 'high', + hasSymmetryGroups: true, + source: 'atom_mapping_data', + }); + }); + + it('falls back to legacy mappings and accepts single-valued Solr fields', () => { + expect(normalizeAtomMapping({ + atom_mapping_data: [null, ' '], + atom_mapping: ' cpd00001:O#1=cpd00009:O#2 ', + atom_mapping_has_symmetry_groups: 'false', + })).toEqual({ + entries: ['cpd00001:O#1=cpd00009:O#2'], + confidence: undefined, + hasSymmetryGroups: false, + source: 'atom_mapping', + }); + }); + + it('derives symmetry groups when Solr does not provide a usable flag', () => { + expect(normalizeAtomMapping({ + atom_mapping_data: 'cpd00001:(O#1;O#2)=cpd00009:O#1', + atom_mapping_has_symmetry_groups: 'unknown', + })).toMatchObject({ hasSymmetryGroups: true, source: 'atom_mapping_data' }); + }); + + it('returns an empty, safe normalization for malformed documents', () => { + expect(normalizeAtomMapping(null)).toEqual({ + entries: [], + confidence: undefined, + hasSymmetryGroups: false, + source: 'none', + }); + expect(normalizeAtomMapping(42 as unknown as null)).toEqual({ + entries: [], + confidence: undefined, + hasSymmetryGroups: false, + source: 'none', + }); + }); +}); From 03e558b065f587ccfb63a79e953b549e29a1a1c5 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 11:09:25 -0500 Subject: [PATCH 09/34] fix(biochem): read the live atom_mapping_data Solr field with legacy fallback --- lib/api/biochem.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/api/biochem.ts b/lib/api/biochem.ts index 6f29fd55..ebb12c9e 100644 --- a/lib/api/biochem.ts +++ b/lib/api/biochem.ts @@ -55,6 +55,10 @@ export interface Reaction { atom_mapping?: string[]; atom_mapping_confidence?: string; has_atom_mapping?: boolean; + /** Live Solr `atom_mapping_data` field. */ + atom_mapping_data?: string[]; + /** Live Solr `atom_mapping_has_symmetry_groups` field. */ + atom_mapping_has_symmetry_groups?: boolean; } export interface Compound { From 28bd56326074a1f3269e9a52521816e93a5ca84e Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 11:09:28 -0500 Subject: [PATCH 10/34] feat(biochem): state symmetry-equivalent atom groups in the mapping UI --- .../biochem/reactions/[id]/page.tsx | 10 +++++--- components/ui/AtomFlowDiagram.tsx | 10 ++++++++ components/ui/AtomMappingSummary.tsx | 25 +++++++++++++++++-- .../unit/components/AtomFlowDiagram.test.tsx | 12 +++++++++ .../components/AtomMappingSummary.test.tsx | 22 ++++++++++++++-- 5 files changed, 71 insertions(+), 8 deletions(-) diff --git a/app/(reference-data)/biochem/reactions/[id]/page.tsx b/app/(reference-data)/biochem/reactions/[id]/page.tsx index 30906447..77fbed65 100644 --- a/app/(reference-data)/biochem/reactions/[id]/page.tsx +++ b/app/(reference-data)/biochem/reactions/[id]/page.tsx @@ -17,7 +17,7 @@ import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation import ThermodynamicsTable from '@/components/ui/ThermodynamicsTable'; import AtomMappingSummary from '@/components/ui/AtomMappingSummary'; import AtomFlowDiagram from '@/components/ui/AtomFlowDiagram'; -import { parseAtomMappings } from '@/lib/utils/atomMapping'; +import { normalizeAtomMapping, parseAtomMappings } from '@/lib/utils/atomMapping'; import { directionAgreementFromRecords, DIRECTION_AGREEMENT_COLOR, @@ -318,7 +318,8 @@ export default function ReactionDetailPage() { enabled: !!id, }); - const atomPairs = useMemo(() => parseAtomMappings(rxn?.atom_mapping), [rxn?.atom_mapping]); + const atomMapping = useMemo(() => normalizeAtomMapping(rxn), [rxn]); + const atomPairs = useMemo(() => parseAtomMappings(atomMapping.entries), [atomMapping.entries]); if (isLoading) { return ( @@ -511,8 +512,9 @@ export default function ReactionDetailPage() { diff --git a/components/ui/AtomFlowDiagram.tsx b/components/ui/AtomFlowDiagram.tsx index 859fee96..81b82a45 100644 --- a/components/ui/AtomFlowDiagram.tsx +++ b/components/ui/AtomFlowDiagram.tsx @@ -15,6 +15,14 @@ const ROW_HEIGHT = 44; export default function AtomFlowDiagram({ pairs }: AtomFlowDiagramProps): React.ReactElement | null { const flows = useMemo(() => summarizeAtomFlows(pairs), [pairs]); + const groupedEdges = useMemo( + () => new Set( + pairs + .filter((pair) => pair.hasSymmetryGroup) + .map((pair) => `${pair.left.compoundId}>${pair.right.compoundId}`), + ), + [pairs], + ); if (flows.length === 0) return null; @@ -50,6 +58,7 @@ export default function AtomFlowDiagram({ pairs }: AtomFlowDiagramProps): React. stroke="#00838f" strokeOpacity="0.65" strokeWidth={strokeWidth(flow.total)} + strokeDasharray={groupedEdges.has(`${flow.from}>${flow.to}`) ? '6 4' : undefined} > {`${flow.from} to ${flow.to}: ${flow.total} atoms (${breakdown})`} @@ -71,6 +80,7 @@ export default function AtomFlowDiagram({ pairs }: AtomFlowDiagramProps): React. ))} Counts are mapped atoms per compound pair; individual atom positions are not shown. + {groupedEdges.size > 0 && A dashed edge carries at least one symmetry-grouped mapping.} ); } diff --git a/components/ui/AtomMappingSummary.tsx b/components/ui/AtomMappingSummary.tsx index e673709a..1b8d3c42 100644 --- a/components/ui/AtomMappingSummary.tsx +++ b/components/ui/AtomMappingSummary.tsx @@ -11,11 +11,13 @@ import { parseAtomMappings, groupAtomMappingsByCompound, countAtomsPerElement, + formatAtomGroup, } from '@/lib/utils/atomMapping'; export interface AtomMappingSummaryProps { entries: readonly string[] | undefined; confidence?: string; + hasSymmetryGroups?: boolean; } function confidenceColor(value: string): 'success' | 'warning' | 'default' { @@ -26,8 +28,13 @@ function confidenceColor(value: string): 'success' | 'warning' | 'default' { const compoundLinkStyle = { color: '#00838f', textDecoration: 'none', fontWeight: 600 }; -export default function AtomMappingSummary({ entries, confidence }: AtomMappingSummaryProps) { +export default function AtomMappingSummary({ + entries, + confidence, + hasSymmetryGroups, +}: AtomMappingSummaryProps) { const pairs = useMemo(() => parseAtomMappings(entries), [entries]); + const grouped = useMemo(() => pairs.filter((pair) => pair.hasSymmetryGroup), [pairs]); const [showAll, setShowAll] = useState(false); if (pairs.length === 0) return null; @@ -47,6 +54,18 @@ export default function AtomMappingSummary({ entries, confidence }: AtomMappingS )}
+ {(hasSymmetryGroups || grouped.length > 0) && ( + + + + A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined. + + + {grouped.length} of {pairs.length} mappings resolve to a symmetry-equivalent group + + + )} + {compoundIds.map((compoundId) => { const counts = elementCounts.get(compoundId); @@ -87,7 +106,9 @@ export default function AtomMappingSummary({ entries, confidence }: AtomMappingS variant="body2" sx={{ fontFamily: 'monospace' }} > - {pair.raw} + {pair.leftAtoms.length > 1 ? 'any of ' : ''}{formatAtomGroup(pair.leftAtoms)} + {' = '} + {pair.rightAtoms.length > 1 ? 'any of ' : ''}{formatAtomGroup(pair.rightAtoms)} ))} diff --git a/tests/unit/components/AtomFlowDiagram.test.tsx b/tests/unit/components/AtomFlowDiagram.test.tsx index aed410d2..259cebfd 100644 --- a/tests/unit/components/AtomFlowDiagram.test.tsx +++ b/tests/unit/components/AtomFlowDiagram.test.tsx @@ -32,5 +32,17 @@ describe('AtomFlowDiagram', () => { expect(getByText('cpd00009').closest('a')?.getAttribute('href')).toBe( '/biochem/compounds/cpd00009', ); + expect(container.querySelector('line')?.getAttribute('stroke-dasharray')).toBeNull(); + expect(container.textContent).not.toContain('A dashed edge carries'); + }); + + it('uses a dashed edge and legend for symmetry-grouped mappings', () => { + const pairs = parseAtomMappings(['cpd00001:(O#1;O#2)=cpd00009:O#1']); + const { container } = render(); + + expect(container.querySelector('line')?.getAttribute('stroke-dasharray')).toBe('6 4'); + expect(container.textContent).toContain( + 'A dashed edge carries at least one symmetry-grouped mapping.', + ); }); }); diff --git a/tests/unit/components/AtomMappingSummary.test.tsx b/tests/unit/components/AtomMappingSummary.test.tsx index 84d8badc..8dab9740 100644 --- a/tests/unit/components/AtomMappingSummary.test.tsx +++ b/tests/unit/components/AtomMappingSummary.test.tsx @@ -56,7 +56,25 @@ describe('AtomMappingSummary', () => { fireEvent.click(getByText('Show all mappings')); - expect(container.textContent).toContain('cpd00001:O#1=cpd00009:O#2'); - expect(container.textContent).toContain('cpd00012:O#4=cpd00009:O#3'); + expect(container.textContent).toContain('O#1 = O#2'); + expect(container.textContent).toContain('O#4 = O#3'); + }); + + it('explains symmetry groups and labels multi-member sides as any of', () => { + const { container, getByText } = render( + , + ); + + expect(container.textContent).toContain('symmetry groups'); + expect(container.textContent).toContain( + 'A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined.', + ); + expect(container.textContent).toContain('1 of 1 mappings resolve to a symmetry-equivalent group'); + + fireEvent.click(getByText('Show all mappings')); + expect(container.textContent).toContain('any of O#1, O#2 = O#3'); }); }); From b601971d6296b409d7c58df1706747d3aa5de72c Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 11:09:38 -0500 Subject: [PATCH 11/34] docs(changelog): record symmetry-group atom mapping support --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be72fa2c..ee4ff845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compound detail page now shows all pKa and pKb values instead of only the first - Reaction detail page now shows an atom-mapping summary with per-compound element counts, a confidence indicator and an expandable raw list - Reaction detail page now visualises atom mappings as a reactant-to-product atom-flow diagram, with one edge per compound pair scaled by the number of mapped atoms and a per-element breakdown +- Reaction atom mappings now disclose symmetry-equivalent groups in summaries and diagrams without claiming a specific atom correspondence - All of the above is feature-detected, so pages render exactly as before against the current production Solr +### Fixed +- Reaction detail pages now read the live Solr `atom_mapping_data` field while retaining legacy `atom_mapping` fallback + ### Changed - Reaction thermodynamics direction agreement is now derived from the per-source direction operators rather than a single server flag, and reports three states: "Sources agree on direction" (all operators identical), "Sources could agree on direction" (only one angle-bracket direction, optionally mixed with `=`) and "Sources disagree on direction" (both `>` and `<` present) From 7e3540877d457aea0a8fa9eaed3acd7f53e1bbeb Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 13:44:50 -0500 Subject: [PATCH 12/34] feat(biochem): colour reaction atom mappings by element block Colour only semantically safe, fully mapped element blocks. Preserve atom identity across both sides without trusting InChI indices as render indices. --- components/ui/MoleculeRenderer.tsx | 66 ++++- lib/utils/atomMappingColors.ts | 259 ++++++++++++++++++++ lib/utils/moleculeHighlights.ts | 117 +++++++++ tests/unit/utils/atomMappingColors.test.ts | 114 +++++++++ tests/unit/utils/moleculeHighlights.test.ts | 115 +++++++++ 5 files changed, 666 insertions(+), 5 deletions(-) create mode 100644 lib/utils/atomMappingColors.ts create mode 100644 lib/utils/moleculeHighlights.ts create mode 100644 tests/unit/utils/atomMappingColors.test.ts create mode 100644 tests/unit/utils/moleculeHighlights.test.ts diff --git a/components/ui/MoleculeRenderer.tsx b/components/ui/MoleculeRenderer.tsx index 0ea9ea67..2a1406ac 100644 --- a/components/ui/MoleculeRenderer.tsx +++ b/components/ui/MoleculeRenderer.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import Image from 'next/image'; import Skeleton from '@mui/material/Skeleton'; import Typography from '@mui/material/Typography'; import { getRDKit } from '@/lib/rdkit'; import { getCompoundImageUrl } from '@/lib/api/biochem'; +import { applyBondColors, buildMoleculeHighlightPlan, elementInventoryFromMolJson } from '@/lib/utils/moleculeHighlights'; /** * Maps atom index (0-based) to a CSS color string. @@ -20,6 +21,10 @@ interface MoleculeRendererProps { compoundId: string; /** Optional per-atom color map for atom-mapping overlays */ atomColors?: AtomColors; + /** Optional per-element color map for RDKit structure highlights */ + elementColors?: Readonly>; + /** Called after a successful RDKit parse with the molecule's element inventory. */ + onInventory?: (inventory: Record) => void; width?: number; height?: number; alt?: string; @@ -31,6 +36,8 @@ export default function MoleculeRenderer({ smiles, compoundId, atomColors, + elementColors, + onInventory, width = 150, height = 150, alt, @@ -38,6 +45,16 @@ export default function MoleculeRenderer({ const [state, setState] = useState('loading'); const [svgString, setSvgString] = useState(''); const atomColorsKey = useMemo(() => JSON.stringify(atomColors ?? {}), [atomColors]); + const elementColorsKey = useMemo(() => JSON.stringify(elementColors ?? {}), [elementColors]); + const onInventoryRef = useRef(onInventory); + const atomColorsRef = useRef(atomColors); + const elementColorsRef = useRef(elementColors); + + useEffect(() => { + onInventoryRef.current = onInventory; + atomColorsRef.current = atomColors; + elementColorsRef.current = elementColors; + }, [onInventory, atomColors, elementColors]); useEffect(() => { let cancelled = false; @@ -56,14 +73,53 @@ export default function MoleculeRenderer({ const mol = RDKit.get_mol(smiles); try { let svg: string; + let molJson: unknown; + const currentElementColors = elementColorsRef.current; + const currentAtomColors = atomColorsRef.current; + if ((currentElementColors && Object.keys(currentElementColors).length > 0) || onInventoryRef.current) { + try { + molJson = JSON.parse(mol.get_json()); + onInventoryRef.current?.(elementInventoryFromMolJson(molJson)); + } catch { + molJson = undefined; + } + } - if (atomColors && Object.keys(atomColors).length > 0) { - const atomIndices = Object.keys(atomColors).map(Number); + if (currentElementColors && Object.keys(currentElementColors).length > 0) { + const plan = molJson + ? buildMoleculeHighlightPlan(molJson, currentElementColors) + : { atomColors: {}, bondColors: {} }; + + const atomIndices = Object.keys(plan.atomColors).map(Number); + if (atomIndices.length > 0) { + const highlightColors: Record = {}; + for (const idx of atomIndices) { + // Convert CSS hex color (#rrggbb) to RDKit [r, g, b] floats + const hex = plan.atomColors[idx].replace('#', ''); + const r = parseInt(hex.slice(0, 2), 16) / 255; + const g = parseInt(hex.slice(2, 4), 16) / 255; + const b = parseInt(hex.slice(4, 6), 16) / 255; + highlightColors[idx] = [r, g, b]; + } + svg = applyBondColors(mol.get_svg_with_highlights( + JSON.stringify({ + atoms: atomIndices, + bonds: [], + highlightAtomColors: highlightColors, + width, + height, + }) + ), plan.bondColors); + } else { + svg = mol.get_svg(width, height); + } + } else if (currentAtomColors && Object.keys(currentAtomColors).length > 0) { + const atomIndices = Object.keys(currentAtomColors).map(Number); const highlightColors: Record = {}; for (const idx of atomIndices) { // Convert CSS hex color (#rrggbb) to RDKit [r, g, b] floats - const hex = atomColors[idx].replace('#', ''); + const hex = currentAtomColors[idx].replace('#', ''); const r = parseInt(hex.slice(0, 2), 16) / 255; const g = parseInt(hex.slice(2, 4), 16) / 255; const b = parseInt(hex.slice(4, 6), 16) / 255; @@ -103,7 +159,7 @@ export default function MoleculeRenderer({ return () => { cancelled = true; }; - }, [smiles, atomColorsKey, atomColors, width, height]); + }, [smiles, atomColorsKey, elementColorsKey, width, height]); if (state === 'loading') { return ( diff --git a/lib/utils/atomMappingColors.ts b/lib/utils/atomMappingColors.ts new file mode 100644 index 00000000..658c2f7f --- /dev/null +++ b/lib/utils/atomMappingColors.ts @@ -0,0 +1,259 @@ +import type { AtomMappingPair, AtomRef } from './atomMapping'; + +/** + * Build whole-element colour assignments for parsed reaction atom mappings. + * + * `lib/utils/atomMapping.ts:15-21` documents that mapping `#N` values are + * 1-based per-element, per-compound indices in InChI canonical atom order, + * not SMILES or RDKit atom indices. RDKit MinimalLib cannot recover that + * order, so this module never emits or accepts atom-index-to-colour mappings: + * an entire (compound, element) block is coloured only after full coverage + * and mutuality make that scientifically safe. + */ + +export type ElementInventory = Readonly>; + +export type UnmappableReason = + | 'no-mapping' + | 'element-mismatch' + | 'multiple-destinations' + | 'structure-unknown' + | 'partial-coverage' + | 'counterpart-unresolved'; + +export interface ElementBlockAssignment { + readonly compoundId: string; + readonly element: string; + readonly colorable: boolean; + readonly color?: string; + readonly groupId?: string; + readonly counterpartCompoundIds: readonly string[]; + readonly mappedIndexCount: number; + readonly structureAtomCount?: number; + readonly reason?: UnmappableReason; +} + +export interface AtomMappingColorLegendEntry { + readonly groupId: string; + readonly color: string; + readonly element: string; + readonly compoundIds: readonly string[]; +} + +export interface AtomMappingColorPlan { + readonly blocks: readonly ElementBlockAssignment[]; + readonly legend: readonly AtomMappingColorLegendEntry[]; + readonly unmappable: readonly ElementBlockAssignment[]; + readonly colorableCount: number; + readonly totalCount: number; +} + +export const MAPPING_PALETTE: readonly string[] = [ + '#0072B2', '#D55E00', '#009E73', '#CC79A7', '#E69F00', '#56B4E9', '#8C564B', + '#7F3FBF', '#BC3C29', '#20854E', '#6F99AD', '#EE4C97', +]; + +interface AccumulatedBlock { + readonly compoundId: string; + readonly element: string; + readonly indices: Set; + readonly counterparts: Set; + readonly counterpartElements: Set; +} + +interface BlockState { + readonly block: AccumulatedBlock; + readonly counterpartCompoundIds: readonly string[]; + readonly structureAtomCount?: number; + readonly basicReason?: UnmappableReason; +} + +function blockKey(compoundId: string, element: string): string { + return `${compoundId}|${element}`; +} + +function isAtomRef(value: unknown): value is AtomRef { + if (!value || typeof value !== 'object') return false; + const ref = value as AtomRef; + return typeof ref.compoundId === 'string' + && typeof ref.element === 'string' + && typeof ref.index === 'number' + && Number.isFinite(ref.index); +} + +function readInventory( + inventories: Readonly> | null | undefined, + compoundId: string, + element: string, +): number | undefined { + const inventory = inventories?.[compoundId]; + const count = inventory?.[element]; + return typeof count === 'number' && Number.isFinite(count) ? count : undefined; +} + +function accumulateBlock( + blocks: Map, + ref: AtomRef, + counterparts: readonly AtomRef[], +): void { + const key = blockKey(ref.compoundId, ref.element); + let block = blocks.get(key); + if (!block) { + block = { + compoundId: ref.compoundId, + element: ref.element, + indices: new Set(), + counterparts: new Set(), + counterpartElements: new Set(), + }; + blocks.set(key, block); + } + + block.indices.add(ref.index); + for (const counterpart of counterparts) { + if (!isAtomRef(counterpart)) continue; + block.counterparts.add(counterpart.compoundId); + block.counterpartElements.add(counterpart.element); + } +} + +function stateFor( + block: AccumulatedBlock, + inventories: Readonly> | null | undefined, +): BlockState { + const counterpartCompoundIds = Array.from(block.counterparts).sort(); + const structureAtomCount = readInventory(inventories, block.compoundId, block.element); + let basicReason: UnmappableReason | undefined; + + if (Array.from(block.counterpartElements).some((element) => element !== block.element)) { + basicReason = 'element-mismatch'; + } else if (counterpartCompoundIds.length !== 1) { + basicReason = 'multiple-destinations'; + } else if (structureAtomCount === undefined) { + basicReason = 'structure-unknown'; + } else if (block.indices.size !== structureAtomCount) { + basicReason = 'partial-coverage'; + } + + return { block, counterpartCompoundIds, structureAtomCount, basicReason }; +} + +/** Build deterministic, whole-element mapping colour assignments without atom-index rendering data. */ +export function buildAtomMappingColorPlan( + pairs: readonly AtomMappingPair[], + inventories: Readonly>, +): AtomMappingColorPlan { + const blocks = new Map(); + for (const pair of Array.isArray(pairs) ? pairs : []) { + const leftAtoms = Array.isArray(pair?.leftAtoms) ? pair.leftAtoms : []; + const rightAtoms = Array.isArray(pair?.rightAtoms) ? pair.rightAtoms : []; + for (const ref of leftAtoms) { + if (isAtomRef(ref)) accumulateBlock(blocks, ref, rightAtoms); + } + for (const ref of rightAtoms) { + if (isAtomRef(ref)) accumulateBlock(blocks, ref, leftAtoms); + } + } + + const states = new Map(); + for (const [key, block] of blocks) states.set(key, stateFor(block, inventories)); + + const colorableGroups = new Map(); + for (const [key, state] of states) { + if (state.basicReason || state.counterpartCompoundIds.length !== 1) continue; + const counterpartCompoundId = state.counterpartCompoundIds[0]; + const counterpartKey = blockKey(counterpartCompoundId, state.block.element); + const counterpart = states.get(counterpartKey); + if (!counterpart || counterpart.basicReason || counterpart.counterpartCompoundIds.length !== 1 + || counterpart.counterpartCompoundIds[0] !== state.block.compoundId) continue; + + const groupId = [key, counterpartKey].sort().join('='); + colorableGroups.set(groupId, { + element: state.block.element, + compoundIds: [state.block.compoundId, counterpartCompoundId].sort(), + }); + } + + const groupColors = new Map(); + const legend = Array.from(colorableGroups.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([groupId, group], index) => { + const color = MAPPING_PALETTE[index % MAPPING_PALETTE.length]; + groupColors.set(groupId, color); + return { groupId, color, element: group.element, compoundIds: group.compoundIds }; + }); + + const assignments = Array.from(states.entries()) + .sort(([, left], [, right]) => left.block.compoundId.localeCompare(right.block.compoundId) + || left.block.element.localeCompare(right.block.element)) + .map(([key, state]): ElementBlockAssignment => { + const counterpartKey = state.counterpartCompoundIds.length === 1 + ? blockKey(state.counterpartCompoundIds[0], state.block.element) + : undefined; + const groupId = counterpartKey ? [key, counterpartKey].sort().join('=') : undefined; + const color = groupId ? groupColors.get(groupId) : undefined; + if (color && groupId) { + return { + compoundId: state.block.compoundId, + element: state.block.element, + colorable: true, + color, + groupId, + counterpartCompoundIds: state.counterpartCompoundIds, + mappedIndexCount: state.block.indices.size, + structureAtomCount: state.structureAtomCount, + }; + } + return { + compoundId: state.block.compoundId, + element: state.block.element, + colorable: false, + counterpartCompoundIds: state.counterpartCompoundIds, + mappedIndexCount: state.block.indices.size, + ...(state.structureAtomCount === undefined ? {} : { structureAtomCount: state.structureAtomCount }), + reason: state.basicReason ?? 'counterpart-unresolved', + }; + }); + const unmappable = assignments.filter((assignment) => !assignment.colorable); + + return { + blocks: assignments, + legend, + unmappable, + colorableCount: assignments.length - unmappable.length, + totalCount: assignments.length, + }; +} + +/** Return only whole-element colours assigned to one compound. */ +export function elementColorsForCompound( + plan: AtomMappingColorPlan, + compoundId: string, +): Readonly> { + const colors: Record = {}; + for (const block of Array.isArray(plan?.blocks) ? plan.blocks : []) { + if (block.colorable && block.compoundId === compoundId && block.color) { + colors[block.element] = block.color; + } + } + return colors; +} + +/** Look up a whole-element assignment, synthesising an explicit no-mapping result when absent. */ +export function blockAssignment( + plan: AtomMappingColorPlan, + compoundId: string, + element: string, +): ElementBlockAssignment { + const found = Array.isArray(plan?.blocks) + ? plan.blocks.find((block) => block.compoundId === compoundId && block.element === element) + : undefined; + return found ?? { + compoundId, + element, + colorable: false, + counterpartCompoundIds: [], + mappedIndexCount: 0, + reason: 'no-mapping', + }; +} diff --git a/lib/utils/moleculeHighlights.ts b/lib/utils/moleculeHighlights.ts new file mode 100644 index 00000000..2bedb010 --- /dev/null +++ b/lib/utils/moleculeHighlights.ts @@ -0,0 +1,117 @@ +export interface RdkitAtomJson { + z?: number; + impHs?: number; +} + +export interface RdkitBondJson { + atoms?: readonly number[]; +} + +export type ElementColorMap = Readonly>; + +export interface MoleculeHighlightPlan { + atomColors: Readonly>; + bondColors: Readonly>; +} + +const ELEMENT_SYMBOLS: Readonly> = { + 1: 'H', 5: 'B', 6: 'C', 7: 'N', 8: 'O', 9: 'F', 11: 'Na', 12: 'Mg', + 14: 'Si', 15: 'P', 16: 'S', 17: 'Cl', 19: 'K', 20: 'Ca', 25: 'Mn', + 26: 'Fe', 27: 'Co', 28: 'Ni', 29: 'Cu', 30: 'Zn', 33: 'As', 34: 'Se', + 35: 'Br', 42: 'Mo', 48: 'Cd', 53: 'I', 74: 'W', 80: 'Hg', +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function moleculeFromMolJson(parsed: unknown): Record | undefined { + if (!isRecord(parsed) || !Array.isArray(parsed.molecules) || !isRecord(parsed.molecules[0])) { + return undefined; + } + return parsed.molecules[0]; +} + +function atomsFromMolJson(parsed: unknown): readonly unknown[] | undefined { + const molecule = moleculeFromMolJson(parsed); + return molecule && Array.isArray(molecule.atoms) ? molecule.atoms : undefined; +} + +export function elementSymbolForAtomicNumber(z: number | undefined): string { + if (z === undefined) return 'C'; + return ELEMENT_SYMBOLS[z] ?? `Z${z}`; +} + +export function elementInventoryFromMolJson(parsed: unknown): Record { + const atoms = atomsFromMolJson(parsed); + if (!atoms) return {}; + + const inventory: Record = {}; + for (const atom of atoms) { + if (!isRecord(atom)) continue; + const z = typeof atom.z === 'number' ? atom.z : undefined; + const symbol = elementSymbolForAtomicNumber(z); + inventory[symbol] = (inventory[symbol] ?? 0) + 1; + const implicitHydrogens = typeof atom.impHs === 'number' ? atom.impHs : 0; + if (implicitHydrogens > 0) inventory.H = (inventory.H ?? 0) + implicitHydrogens; + } + return inventory; +} + +export function buildMoleculeHighlightPlan( + parsed: unknown, + elementColors: ElementColorMap, +): MoleculeHighlightPlan { + const atoms = atomsFromMolJson(parsed); + const molecule = moleculeFromMolJson(parsed); + if (!atoms || !molecule || !isRecord(elementColors)) { + return { atomColors: {}, bondColors: {} }; + } + + const atomColors: Record = {}; + for (const [index, atom] of atoms.entries()) { + if (!isRecord(atom)) continue; + const symbol = elementSymbolForAtomicNumber(typeof atom.z === 'number' ? atom.z : undefined); + const color = elementColors[symbol]; + // RDKit get_json array positions, not atom-mapping #N values, define renderer indices. + if (typeof color === 'string') atomColors[index] = color; + } + + const bondColors: Record = {}; + const bonds = Array.isArray(molecule.bonds) ? molecule.bonds : []; + for (const [index, bond] of bonds.entries()) { + if (!isRecord(bond) || !Array.isArray(bond.atoms) || bond.atoms.length < 2) continue; + const [left, right] = bond.atoms; + if (!Number.isInteger(left) || !Number.isInteger(right) + || left < 0 || right < 0 || left >= atoms.length || right >= atoms.length) continue; + const leftColor = atomColors[left]; + const rightColor = atomColors[right]; + if (leftColor !== undefined && leftColor === rightColor) bondColors[index] = leftColor; + } + + return { atomColors, bondColors }; +} + +export function applyBondColors(svg: string, bondColors: Readonly>): string { + if (typeof svg !== 'string' || !bondColors || Object.keys(bondColors).length === 0) return svg; + + try { + return svg.replace(/<[^>]+>/g, (tag) => { + const classMatch = /\bclass=(['"])(.*?)\1/.exec(tag); + if (!classMatch) return tag; + const bondMatch = /^bond-(\d+)(?:\s|$)/.exec(classMatch[2]); + if (!bondMatch) return tag; + const color = bondColors[Number(bondMatch[1])]; + if (typeof color !== 'string') return tag; + return tag + .replace(/\bstyle=(['"])(.*?)\1/, (_styleAttribute, quote, style) => ( + `style=${quote}${style.replace(/stroke:\s*#[0-9a-f]{6}/gi, `stroke:${color}`)}${quote}` + )) + .replace(/\bstroke=(['"])#[0-9a-f]{6}\1/gi, (_strokeAttribute, quote) => ( + `stroke=${quote}${color}${quote}` + )); + }); + } catch { + return svg; + } +} diff --git a/tests/unit/utils/atomMappingColors.test.ts b/tests/unit/utils/atomMappingColors.test.ts new file mode 100644 index 00000000..a53251c1 --- /dev/null +++ b/tests/unit/utils/atomMappingColors.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { parseAtomMappings, type AtomMappingPair } from '@/lib/utils/atomMapping'; +import { + blockAssignment, + buildAtomMappingColorPlan, + elementColorsForCompound, + MAPPING_PALETTE, +} from '@/lib/utils/atomMappingColors'; + +const REAL_ENTRIES = [ + 'cpd00001:O#1=cpd00009:(O#1;O#2;O#3;O#4)', + 'cpd00012:(O#1;O#2;O#3;O#4;O#5;O#6)=cpd00009:(O#1;O#2;O#3;O#4)', + 'cpd00012:(P#1;P#2)=cpd00009:P#1', + 'cpd00012:O#7=cpd00009:(O#1;O#2;O#3;O#4)', +]; + +function pairs(entries: readonly string[]): AtomMappingPair[] { + return parseAtomMappings(entries); +} + +describe('buildAtomMappingColorPlan', () => { + it('safely colours only the mutually covered phosphorus blocks in the real rxn00001 payload', () => { + const plan = buildAtomMappingColorPlan(pairs(REAL_ENTRIES), { + cpd00001: { O: 1 }, + cpd00012: { P: 2, O: 7 }, + cpd00009: { P: 1, O: 4 }, + }); + const leftP = blockAssignment(plan, 'cpd00012', 'P'); + const rightP = blockAssignment(plan, 'cpd00009', 'P'); + + expect(leftP.colorable).toBe(true); + expect(rightP.colorable).toBe(true); + // The parenthesized P group contributes both distinct mapped references. + expect(leftP.mappedIndexCount).toBe(2); + expect(leftP.groupId).toBe(rightP.groupId); + expect(leftP.color).toBe(MAPPING_PALETTE[0]); + expect(rightP.color).toBe(MAPPING_PALETTE[0]); + expect(blockAssignment(plan, 'cpd00009', 'O').reason).toBe('multiple-destinations'); + expect(blockAssignment(plan, 'cpd00001', 'O').reason).toBe('counterpart-unresolved'); + expect(blockAssignment(plan, 'cpd00012', 'O').reason).toBe('counterpart-unresolved'); + expect(plan.colorableCount).toBe(2); + expect(plan.legend).toHaveLength(1); + }); + + it('reports partial coverage when mapped indices do not cover the rendered structure', () => { + const plan = buildAtomMappingColorPlan(pairs(['cpd00001:O#1=cpd00002:O#1']), { + cpd00001: { O: 2 }, cpd00002: { O: 1 }, + }); + expect(blockAssignment(plan, 'cpd00001', 'O').reason).toBe('partial-coverage'); + }); + + it('reports unknown structures when an inventory is absent', () => { + const plan = buildAtomMappingColorPlan(pairs(['cpd00001:O#1=cpd00002:O#1']), {}); + expect(plan.blocks.map((block) => block.reason)).toEqual(['structure-unknown', 'structure-unknown']); + }); + + it('reports element mismatches from hand-built parsed pairs', () => { + const plan = buildAtomMappingColorPlan([{ + left: { compoundId: 'cpd00001', element: 'O', index: 1 }, + right: { compoundId: 'cpd00002', element: 'P', index: 1 }, + leftAtoms: [{ compoundId: 'cpd00001', element: 'O', index: 1 }], + rightAtoms: [{ compoundId: 'cpd00002', element: 'P', index: 1 }], + hasSymmetryGroup: false, + raw: 'hand-built', + }], { cpd00001: { O: 1 }, cpd00002: { P: 1 } }); + expect(blockAssignment(plan, 'cpd00001', 'O').reason).toBe('element-mismatch'); + expect(blockAssignment(plan, 'cpd00002', 'P').reason).toBe('element-mismatch'); + }); + + it('synthesises no-mapping assignments for absent blocks', () => { + const plan = buildAtomMappingColorPlan([], {}); + expect(blockAssignment(plan, 'cpd00001', 'O')).toMatchObject({ + colorable: false, counterpartCompoundIds: [], mappedIndexCount: 0, reason: 'no-mapping', + }); + }); + + it('is deterministic and deduplicates repeated indices', () => { + const input = pairs([ + 'cpd00001:(O#1;O#1)=cpd00002:(O#1;O#1)', + ]); + const inventories = { cpd00001: { O: 1 }, cpd00002: { O: 1 } }; + expect(buildAtomMappingColorPlan(input, inventories)).toEqual(buildAtomMappingColorPlan(input, inventories)); + expect(blockAssignment(buildAtomMappingColorPlan(input, inventories), 'cpd00001', 'O').mappedIndexCount).toBe(1); + }); + + it('cycles the palette after twelve sorted groups', () => { + const entries = Array.from({ length: 13 }, (_, index) => + `cpd${String(index + 1).padStart(5, '0')}:O#1=cpd${String(index + 101).padStart(5, '0')}:O#1`, + ); + const inventories = Object.fromEntries(Array.from({ length: 13 }, (_, index) => [ + `cpd${String(index + 1).padStart(5, '0')}`, { O: 1 }, + ]).concat(Array.from({ length: 13 }, (_, index) => [ + `cpd${String(index + 101).padStart(5, '0')}`, { O: 1 }, + ]))); + const plan = buildAtomMappingColorPlan(pairs(entries), inventories); + expect(plan.legend).toHaveLength(13); + expect(plan.legend[12].color).toBe(MAPPING_PALETTE[0]); + expect(new Set(plan.legend.map((entry) => entry.groupId)).size).toBe(13); + }); + + it('returns only colourable element colours and an empty object for unknown compounds', () => { + const plan = buildAtomMappingColorPlan(pairs(REAL_ENTRIES), { + cpd00001: { O: 1 }, cpd00012: { P: 2, O: 7 }, cpd00009: { P: 1, O: 4 }, + }); + expect(elementColorsForCompound(plan, 'cpd00012')).toEqual({ P: MAPPING_PALETTE[0] }); + expect(elementColorsForCompound(plan, 'missing')).toEqual({}); + }); + + it('returns empty plans without throwing for empty pairs and inventories', () => { + expect(buildAtomMappingColorPlan([], {})).toEqual({ + blocks: [], legend: [], unmappable: [], colorableCount: 0, totalCount: 0, + }); + }); +}); diff --git a/tests/unit/utils/moleculeHighlights.test.ts b/tests/unit/utils/moleculeHighlights.test.ts new file mode 100644 index 00000000..58a2b60f --- /dev/null +++ b/tests/unit/utils/moleculeHighlights.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + applyBondColors, + buildMoleculeHighlightPlan, + elementInventoryFromMolJson, + elementSymbolForAtomicNumber, +} from '@/lib/utils/moleculeHighlights'; + +const BOND_PATH = ""; + +describe('moleculeHighlights', () => { + it('maps RDKit atomic numbers, including omitted carbon', () => { + expect(elementSymbolForAtomicNumber(undefined)).toBe('C'); + expect(elementSymbolForAtomicNumber(8)).toBe('O'); + expect(elementSymbolForAtomicNumber(15)).toBe('P'); + expect(elementSymbolForAtomicNumber(999)).toBe('Z999'); + }); + + it('includes implicit hydrogens in inventories', () => { + expect(elementInventoryFromMolJson({ + molecules: [{ atoms: [{ z: 8, impHs: 2 }], bonds: [] }], + })).toEqual({ O: 1, H: 2 }); + }); + + it('treats omitted atomic numbers as carbon in inventories', () => { + expect(elementInventoryFromMolJson({ + molecules: [{ atoms: [{}, {}, { z: 8 }, { z: 8 }], bonds: [] }], + })).toEqual({ C: 2, O: 2 }); + }); + + it('safely returns empty inventories and plans for malformed input', () => { + for (const value of [null, 'x', {}, { molecules: [] }]) { + expect(elementInventoryFromMolJson(value)).toEqual({}); + expect(buildMoleculeHighlightPlan(value, { O: '#ff0000' })).toEqual({ + atomColors: {}, bondColors: {}, + }); + } + }); + + it('colours only bonds between atoms with the same colour', () => { + const plan = buildMoleculeHighlightPlan({ + molecules: [{ + atoms: [{ z: 8 }, { z: 8 }, {}, { z: 7 }], + bonds: [{ atoms: [0, 1] }, { atoms: [1, 2] }, { atoms: [0, 3] }], + }], + }, { O: '#ff0000', N: '#0000ff' }); + + expect(plan.atomColors).toEqual({ 0: '#ff0000', 1: '#ff0000', 3: '#0000ff' }); + expect(plan.bondColors).toEqual({ 0: '#ff0000' }); + }); + + it('recolours a bond stroke without changing its path or fill', () => { + const result = applyBondColors(BOND_PATH, { 0: '#00ff00' }); + expect(result).toContain('stroke:#00ff00'); + expect(result).toContain('fill:none'); + expect(result).toContain("d='M 20.3,245.0 L 130.2,181.5'"); + }); + + it('does not confuse bond index prefixes', () => { + const svg = ""; + expect(applyBondColors(svg, { 3: '#00ff00' })).toBe(svg); + }); + + it('returns the identical SVG for an empty colour map', () => { + expect(applyBondColors(BOND_PATH, {})).toBe(BOND_PATH); + }); + + it('handles double-quoted class and style attributes', () => { + const svg = ''; + expect(applyBondColors(svg, { 0: '#abcdef' })).toContain('stroke:#abcdef'); + }); + + it('recolours a heteroatom half-bond', () => { + const svg = ""; + const result = applyBondColors(svg, { 0: '#0072B2' }); + expect(result).toContain('stroke:#0072B2'); + expect(result).not.toContain('#FF0000'); + }); + + it('recolours both halves of a bond', () => { + const svg = `${BOND_PATH}`; + const result = applyBondColors(svg, { 0: '#0072B2' }); + expect(result.match(/stroke:#0072B2/g)).toHaveLength(2); + }); + + it('recolours every hex stroke declaration in a bond style', () => { + const svg = ""; + expect(applyBondColors(svg, { 0: '#0072B2' })).toBe( + "", + ); + }); + + it('recolours standalone stroke attributes', () => { + const svg = ''; + expect(applyBondColors(svg, { 1: '#009E73' })).toContain('stroke="#009E73"'); + }); + + it('leaves fills and atom label glyphs untouched', () => { + const atomLabel = ""; + const bond = ""; + const result = applyBondColors(`${atomLabel}${bond}`, { 0: '#0072B2' }); + expect(result).toContain(atomLabel); + expect(result).toContain('fill:none;stroke:#0072B2'); + }); + + it('leaves non-hex bond strokes unchanged', () => { + const svg = ""; + expect(applyBondColors(svg, { 0: '#0072B2' })).toBe(svg); + }); + + it('recolours lowercase hex bond strokes', () => { + const svg = ""; + expect(applyBondColors(svg, { 0: '#0072B2' })).toContain('stroke:#0072B2'); + }); +}); From cf36596e3f37963ee60763ad49deedbc5a8b2657 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 13:44:50 -0500 Subject: [PATCH 13/34] feat(biochem): draw reactions on an open structure canvas Present reaction structures as one continuous equation rather than compound cards. Keep compound context readable and linkable while retaining chemical notation. --- components/ui/ReactionStructureEquation.tsx | 595 +++++------------- .../ReactionStructureEquation.test.tsx | 127 ++++ 2 files changed, 291 insertions(+), 431 deletions(-) create mode 100644 tests/unit/components/ReactionStructureEquation.test.tsx diff --git a/components/ui/ReactionStructureEquation.tsx b/components/ui/ReactionStructureEquation.tsx index 7a9325f9..1f8ff7ca 100644 --- a/components/ui/ReactionStructureEquation.tsx +++ b/components/ui/ReactionStructureEquation.tsx @@ -1,13 +1,21 @@ 'use client'; import dynamic from 'next/dynamic'; -import Link from 'next/link'; -import { useMemo, memo } from 'react'; +import NextLink from 'next/link'; +import { useCallback, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; import Skeleton from '@mui/material/Skeleton'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; import { getCompoundsForReaction } from '@/lib/api/biochem'; +import type { AtomMappingPair } from '@/lib/utils/atomMapping'; +import { + buildAtomMappingColorPlan, + elementColorsForCompound, + type UnmappableReason, +} from '@/lib/utils/atomMappingColors'; import type { AtomColors } from './MoleculeRenderer'; const MoleculeRenderer = dynamic(() => import('./MoleculeRenderer'), { @@ -15,484 +23,209 @@ const MoleculeRenderer = dynamic(() => import('./MoleculeRenderer'), { loading: () => , }); -/* ─── Types ──────────────────────────────────────────────────── */ - -/** - * Future atom-mapping data shape. - * Key is compound ID, value maps atom index → CSS color string. - * Pass this prop once atom-mapping data is available. - */ export type ReactionAtomMapping = Record; interface ReactionStructureEquationProps { - /** Raw equation string e.g. "(2) cpd00001[c] + cpd00012[c] => cpd00009[c] + cpd00067[c]" */ equation?: string; - /** Reaction reversibility field from Solr ("=", "<=>", "=>", etc.) */ reversibility?: string; - /** Optional atom-mapping overlay data — not yet available, reserved for summer integration */ atomMapping?: ReactionAtomMapping; + /** Parsed atom-mapping pairs for this reaction; enables structural fate colouring. */ + atomMappingPairs?: readonly AtomMappingPair[]; + /** Confidence label from the Solr field, e.g. 'clean' | 'salvaged'. */ + atomMappingConfidence?: string; + /** True when the source data contained symmetry-equivalent atom groups. */ + atomMappingHasSymmetryGroups?: boolean; } -/* ─── Equation Parser ────────────────────────────────────────── */ - -interface CompoundToken { - id: string; - stoich: string; -} +interface CompoundToken { id: string; stoich: string; } +interface ParsedEquation { reactants: CompoundToken[]; products: CompoundToken[]; arrow: string; } +type Inventory = Record; +type DisplayData = { name?: string; smiles?: string; formula?: string; charge?: number }; -interface ParsedEquation { - reactants: CompoundToken[]; - products: CompoundToken[]; - arrow: string; -} +const EMPTY_PARSED: ParsedEquation = { reactants: [], products: [], arrow: '⇒' }; +const EMPTY_MAP = new Map(); +const REASON_TEXT: Record = { + 'no-mapping': 'no mapping data', + 'element-mismatch': 'element mismatch between sides', + 'multiple-destinations': 'atoms split across multiple products', + 'structure-unknown': 'structure unavailable', + 'partial-coverage': 'mapping covers only part of the structure', + 'counterpart-unresolved': 'the corresponding atoms could not be resolved', +}; +const compoundLinkStyle = { color: '#00838f', textDecoration: 'none', fontWeight: 600 }; function parseEquation(equation: string): ParsedEquation { - // Determine arrow type and split let arrow = '⇒'; let lhs = equation; let rhs = ''; - - if (equation.includes('<=>')) { - arrow = '⇌'; - [lhs, rhs] = equation.split('<=>'); - } else if (equation.includes('=>')) { - arrow = '⇒'; - [lhs, rhs] = equation.split('=>'); - } else if (equation.includes('<=')) { - arrow = '⇐'; - [lhs, rhs] = equation.split('<='); - } else if (equation.includes('-->')) { - arrow = '⇒'; - [lhs, rhs] = equation.split('-->'); - } - - return { - reactants: parseSide(lhs ?? ''), - products: parseSide(rhs ?? ''), - arrow, - }; + if (equation.includes('<=>')) { arrow = '⇌'; [lhs, rhs] = equation.split('<=>'); } + else if (equation.includes('=>')) { [lhs, rhs] = equation.split('=>'); } + else if (equation.includes('<=')) { arrow = '⇐'; [lhs, rhs] = equation.split('<='); } + else if (equation.includes('-->')) { [lhs, rhs] = equation.split('-->'); } + return { reactants: parseSide(lhs ?? ''), products: parseSide(rhs ?? ''), arrow }; } function parseSide(side: string): CompoundToken[] { - return side - .split('+') - .map((token) => token.trim()) - .filter(Boolean) - .map((token) => { - // Remove compartment brackets e.g. [c], [0] - const cleaned = token.replace(/\[\w+\]/g, '').trim(); - - // Extract leading stoichiometry e.g. "(2)" or "2 " - const stoichMatch = cleaned.match(/^\(?([\d.]+)\)?\s*/); - const stoich = stoichMatch && stoichMatch[1] !== '1' ? stoichMatch[1] : ''; - const rest = cleaned.replace(/^\(?([\d.]+)\)?\s*/, '').trim(); - - const idMatch = rest.match(/cpd\d{5}/); - const id = idMatch ? idMatch[0] : rest; - - return { id, stoich }; - }) - .filter((t) => t.id.startsWith('cpd')); -} - -/* ─── Tooltip Content ────────────────────────────────────────── */ - -interface CompoundTooltipProps { - compoundId: string; - name?: string; - formula?: string; - synonyms?: string[]; + return side.split('+').map((token) => token.trim()).filter(Boolean).map((token) => { + const cleaned = token.replace(/\[\w+\]/g, '').trim(); + const stoichMatch = cleaned.match(/^\(?([\d.]+)\)?\s*/); + const stoich = stoichMatch && stoichMatch[1] !== '1' ? stoichMatch[1] : ''; + const rest = cleaned.replace(/^\(?([\d.]+)\)?\s*/, '').trim(); + const idMatch = rest.match(/cpd\d{5}/); + return { id: idMatch ? idMatch[0] : rest, stoich }; + }).filter((token) => token.id.startsWith('cpd')); } -const SUBSCRIPT_MAP: Record = { - '0': '₀', - '1': '₁', - '2': '₂', - '3': '₃', - '4': '₄', - '5': '₅', - '6': '₆', - '7': '₇', - '8': '₈', - '9': '₉', - '+': '₊', - '-': '₋', - '(': '₍', - ')': '₎', -}; - -const SUPERSCRIPT_MAP: Record = { - '0': '⁰', - '1': '¹', - '2': '²', - '3': '³', - '4': '⁴', - '5': '⁵', - '6': '⁶', - '7': '⁷', - '8': '⁸', - '9': '⁹', - '+': '⁺', - '-': '⁻', - '(': '⁽', - ')': '⁾', -}; - -function toMappedScript(value: string, map: Record): string { - return value - .split('') - .map((ch) => map[ch] ?? ch) - .join(''); +function isSimpleIon(inventory: Inventory | undefined): boolean { + return inventory !== undefined + && Object.entries(inventory).filter(([element]) => element !== 'H') + .reduce((total, [, count]) => total + count, 0) <= 3; } -function formatChemicalText(value: string): string { - if (!value) return value; - - let text = value.trim(); - - // Normalize any HTML sub/sup tags from legacy synonym strings. - text = text - .replace(/<\s*sub\s*>(.*?)<\s*\/\s*sub\s*>/gi, (_, inner: string) => toMappedScript(inner, SUBSCRIPT_MAP)) - .replace(/<\s*sup\s*>(.*?)<\s*\/\s*sup\s*>/gi, (_, inner: string) => toMappedScript(inner, SUPERSCRIPT_MAP)) - .replace(/<[^>]+>/g, ''); - - // Convert element-number patterns (H2O, PO4, O3) to Unicode subscripts. - text = text.replace(/([A-Za-z\)\]])(\d+)/g, (_, prev: string, digits: string) => `${prev}${toMappedScript(digits, SUBSCRIPT_MAP)}`); - - // Convert trailing charge notation e.g. "(2-)" -> "²⁻" and "( - )"/"(+)". - text = text.replace(/\((\d*[+-]|[+-]\d*)\)\s*$/g, (_, charge: string) => toMappedScript(charge, SUPERSCRIPT_MAP)); - - // Convert non-parenthesized trailing charges e.g. H2PO4- or PO43-. - text = text.replace(/([A-Za-z₀-₉\]\)])(\d*[+-]|[+-]\d*)$/g, (_, stem: string, charge: string) => `${stem}${toMappedScript(charge, SUPERSCRIPT_MAP)}`); - - return text.replace(/\s+/g, ' ').trim(); +function formatCharge(charge: number | undefined): string { + if (!charge) return ''; + return `${Math.abs(charge) === 1 ? '' : Math.abs(charge)}${charge > 0 ? '+' : '-'}`; } -function normalizeSynonyms(rawSynonyms: string[] | undefined): string[] { - if (!rawSynonyms || rawSynonyms.length === 0) return []; - - const seen = new Set(); - const result: string[] = []; - - for (const raw of rawSynonyms) { - const formatted = formatChemicalText(raw); - if (!formatted) continue; - const dedupeKey = formatted.toLowerCase(); - if (seen.has(dedupeKey)) continue; - seen.add(dedupeKey); - result.push(formatted); - } - - return result; +function directionText(arrow: string): string { + if (arrow === '⇌') return 'reversible reaction'; + if (arrow === '⇐') return 'reaction proceeds right to left'; + return 'reaction proceeds left to right'; } -function CompoundTooltipContent({ compoundId, name, formula, synonyms }: CompoundTooltipProps) { - const formattedFormula = formula ? formatChemicalText(formula) : undefined; - const formattedSynonyms = normalizeSynonyms(synonyms); - - return ( - - - {name ?? compoundId} - - - ID: {compoundId} - - {formattedFormula && ( - - Formula: {formattedFormula} - - )} - {formattedSynonyms.length > 0 && ( - - - Synonyms: - - {formattedSynonyms.slice(0, 8).map((syn) => ( - - • {syn} - - ))} - - )} - - ); +function confidenceColor(value: string): 'success' | 'warning' | 'default' { + if (value === 'clean') return 'success'; + if (value === 'salvaged') return 'warning'; + return 'default'; } -/* ─── Compound Card ──────────────────────────────────────────── */ - -interface CompoundCardProps { +interface CompoundColumnProps { token: CompoundToken; - smiles?: string; - name?: string; - formula?: string; - synonyms?: string[]; + data?: DisplayData; + inventory?: Inventory; atomColors?: AtomColors; + elementColors?: Readonly>; + mappingDescription?: string; + onInventory: (inventory: Inventory) => void; } -const CompoundCard = memo(function CompoundCard({ token, smiles, name, formula, synonyms, atomColors }: CompoundCardProps) { - return ( - .cpd-tooltip': { - opacity: 1, - visibility: 'visible', - }, - '&:hover .mol-wrapper': { - boxShadow: '0 2px 8px rgba(0,0,0,0.15)', - transform: 'translateY(-2px)', - }, - }} - > - {token.stoich && ( - - ({token.stoich}) - - )} - - - - - - - - - {token.id} - - - {/* Below image + ID — avoids clipping under the equation row above */} - - - - +function CompoundColumn({ token, data, inventory, atomColors, elementColors, mappingDescription, onInventory }: CompoundColumnProps) { + const simple = isSimpleIon(inventory); + const label = data?.name || token.id; + const metadata = [token.id, data?.formula, formatCharge(data?.charge)].filter(Boolean).join(' · '); + const contents = simple ? ( + + {label}{formatCharge(data?.charge) && {formatCharge(data?.charge)}} + + ) : ( + ); -}); - -/* ─── Side (reactants or products) ──────────────────────────── */ - -function EquationSide({ - tokens, - compoundMap, - atomMapping, -}: { - tokens: CompoundToken[]; - compoundMap: Map; - atomMapping?: ReactionAtomMapping; -}) { return ( - - {tokens.map((token, idx) => { - const data = compoundMap.get(token.id); - return ( - - - {idx < tokens.length - 1 && ( - - + - - )} + + {token.stoich && {token.stoich}} + + + + {contents} - ); - })} + + {!simple && + {label} + } + {metadata} + ); } -/* ─── Main Component ─────────────────────────────────────────── */ - -type DisplayData = { name?: string; smiles?: string; formula?: string; synonyms?: string[] }; -const EMPTY_PARSED: ParsedEquation = { reactants: [], products: [], arrow: '⇒' }; -const EMPTY_MAP = new Map(); - -export default function ReactionStructureEquation({ - equation, - reversibility, - atomMapping, -}: ReactionStructureEquationProps) { - // All hooks must be called unconditionally before any early return. - // Previously the early return was before the hooks, which violated - // Rules of Hooks and caused unpredictable render-loop behavior. - const parsed = useMemo( - () => (equation ? parseEquation(equation) : EMPTY_PARSED), - [equation] - ); +function EquationSide({ tokens, displayMap, inventories, atomMapping, useElementColors, plan, callbacks }: { + tokens: CompoundToken[]; displayMap: Map; inventories: Record; + atomMapping?: ReactionAtomMapping; useElementColors: boolean; plan: ReturnType; + callbacks: Readonly void>>; +}) { + const ordered = useMemo(() => { + const drawn = tokens.filter((token) => !isSimpleIon(inventories[token.id])); + return [...drawn, ...tokens.filter((token) => isSimpleIon(inventories[token.id]))]; + }, [tokens, inventories]); + return + {ordered.map((token, index) => { + const elementColors = useElementColors ? elementColorsForCompound(plan, token.id) : undefined; + const colors = elementColors && Object.keys(elementColors).length > 0 ? elementColors : undefined; + const descriptions = plan.blocks.filter((block) => block.colorable && block.compoundId === token.id) + .map((block) => `${block.element} mapped to ${block.counterpartCompoundIds.join(', ')}`); + return + + {index < ordered.length - 1 && } + ; + })} + ; +} +export default function ReactionStructureEquation({ equation, reversibility, atomMapping, atomMappingPairs, atomMappingConfidence, atomMappingHasSymmetryGroups }: ReactionStructureEquationProps) { + const parsed = useMemo(() => equation ? parseEquation(equation) : EMPTY_PARSED, [equation]); const arrow = useMemo(() => { - let a = parsed.arrow; - if (reversibility === '=' || reversibility === '<=>') a = '⇌'; - else if (reversibility === '>') a = '⇒'; - else if (reversibility === '<') a = '⇐'; - return a; + if (reversibility === '=' || reversibility === '<=>') return '⇌'; + if (reversibility === '>') return '⇒'; + if (reversibility === '<') return '⇐'; + return parsed.arrow; }, [parsed.arrow, reversibility]); - - const allIds = useMemo( - () => [...parsed.reactants.map((t) => t.id), ...parsed.products.map((t) => t.id)], - [parsed] - ); + const allIds = useMemo(() => [...parsed.reactants, ...parsed.products].map((token) => token.id), [parsed]); const uniqueCompoundIds = useMemo(() => Array.from(new Set(allIds)), [allIds]); const compoundIdsKey = useMemo(() => [...uniqueCompoundIds].sort().join(','), [uniqueCompoundIds]); - const { data: compoundMap, isLoading } = useQuery({ - queryKey: ['reaction-structure-compounds', compoundIdsKey], - queryFn: () => getCompoundsForReaction(uniqueCompoundIds), - enabled: uniqueCompoundIds.length > 0, - staleTime: 5 * 60 * 1000, + queryKey: ['reaction-structure-compounds', compoundIdsKey], queryFn: () => getCompoundsForReaction(uniqueCompoundIds), + enabled: uniqueCompoundIds.length > 0, staleTime: 5 * 60 * 1000, }); - - // Memoize the display map — creating a new Map() on every render passes - // new object references into CompoundCard props, triggering continuous - // re-renders even when the underlying data has not changed. const displayMap = useMemo>(() => { if (!compoundMap) return EMPTY_MAP; - const map = new Map(); - for (const [id, cpd] of compoundMap.entries()) { - const synonymEntry = cpd.aliases?.find((a) => a.startsWith('Name:')); - const synonyms = synonymEntry - ? synonymEntry.replace('Name:', '').replace(/"/g, '').split(';').map((s) => s.trim()).filter(Boolean) - : []; - map.set(id, { - name: cpd.name, - smiles: cpd.smiles, - formula: cpd.formula, - synonyms, - }); - } - return map; + return new Map(Array.from(compoundMap.entries(), ([id, compound]) => [id, { + name: compound.name, smiles: compound.smiles, formula: compound.formula, charge: compound.charge, + }])); }, [compoundMap]); + const [inventories, setInventories] = useState>({}); + const saveInventory = useCallback((compoundId: string, inventory: Inventory) => { + setInventories((previous) => JSON.stringify(previous[compoundId] ?? {}) === JSON.stringify(inventory) + ? previous : { ...previous, [compoundId]: inventory }); + }, []); + const inventoryCallbacks = useMemo(() => Object.fromEntries(uniqueCompoundIds.map((id) => [id, (inventory: Inventory) => saveInventory(id, inventory)])), [uniqueCompoundIds, saveInventory]); + const pairs = useMemo(() => atomMappingPairs ?? [], [atomMappingPairs]); + const useElementColors = pairs.length > 0; + const plan = useMemo(() => buildAtomMappingColorPlan(pairs, inventories), [pairs, inventories]); + const reasons = useMemo(() => Array.from(new Set(plan.unmappable.map((block) => block.reason).filter((reason): reason is UnmappableReason => Boolean(reason)))).map((reason) => REASON_TEXT[reason]), [plan]); - // Safe to early-return after all hooks have been called. if (!equation) return null; + if (isLoading) return {allIds.map((id, index) => )}; - if (isLoading) { - return ( - - {allIds.map((id) => ( - - ))} - - ); - } - - return ( - - {/* Reactants */} - - - {/* Arrow */} - - {arrow} - - - {/* Products */} - + return + + + + - ); + {useElementColors && + {(plan.colorableCount > 0 || atomMappingConfidence || atomMappingHasSymmetryGroups) && + {plan.colorableCount > 0 && Atom mapping} + {atomMappingConfidence && } + {atomMappingHasSymmetryGroups && A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined.} + } + {plan.colorableCount > 0 && + {plan.legend.map((entry) => + )} + } + {reasons.length > 0 && Some atoms could not be unambiguously mapped and therefore are not coloured: {reasons.join('; ')}.} + } + ; } diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx new file mode 100644 index 00000000..dab2ce9d --- /dev/null +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { parseAtomMappings } from '@/lib/utils/atomMapping'; +import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation'; + +const rendererCalls: Array> = []; +const compounds = new Map([ + ['cpd00001', { name: 'Water', smiles: 'O', formula: 'H2O', charge: 0 }], + ['cpd00012', { name: 'Phosphate donor', smiles: 'OP(=O)(O)O', formula: 'H4O7P2', charge: -2 }], + ['cpd00009', { name: 'Phosphate', smiles: 'OP(=O)(O)O', formula: 'H3O4P', charge: -1 }], +]); + +vi.mock('@/lib/api/biochem', () => ({ getCompoundsForReaction: vi.fn(async () => compounds) })); +vi.mock('@/components/ui/MoleculeRenderer', () => ({ + default: (props: Record) => { + rendererCalls.push(props); + const inventories: Record> = { + cpd00001: { O: 1, H: 2 }, cpd00012: { P: 2, O: 7, H: 4 }, cpd00009: { P: 1, O: 4, H: 3 }, + }; + (props.onInventory as ((inventory: Record) => void) | undefined)?.(inventories[props.compoundId as string] ?? { C: 4 }); + return
; + }, +})); + +function renderEquation(props: Partial> = {}) { + rendererCalls.length = 0; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(); +} + +const pairs = parseAtomMappings([ + 'cpd00001:O#1=cpd00009:(O#1;O#2;O#3;O#4)', + 'cpd00012:(O#1;O#2;O#3;O#4;O#5;O#6)=cpd00009:(O#1;O#2;O#3;O#4)', + 'cpd00012:(P#1;P#2)=cpd00009:P#1', + 'cpd00012:O#7=cpd00009:(O#1;O#2;O#3;O#4)', +]); + +describe('ReactionStructureEquation', () => { + it('renders an open equation with operators and prominent linked names', async () => { + const { container, getByText } = renderEquation(); + await waitFor(() => expect(getByText('Water')).toBeTruthy()); + expect(container.querySelector('.mol-wrapper')).toBeNull(); + expect(container.querySelector('[data-testid="structure-cpd00001"]')).toBeNull(); + expect(container.textContent).toContain('+'); + expect(container.textContent).toContain('⇒'); + expect(getByText('Water', { selector: 'a p' }).closest('a')?.getAttribute('href')).toBe('/biochem/compounds/cpd00001'); + expect(container.textContent).toContain('cpd00001'); + }); + + it('uses one phosphorus colour on both compounds and discloses ambiguous mappings', async () => { + const { container } = renderEquation({ atomMappingPairs: pairs }); + await waitFor(() => expect(container.textContent).toContain('Atom mapping')); + expect(container.querySelectorAll('[aria-label="Atom mapping legend"] li')).toHaveLength(1); + await waitFor(() => expect(rendererCalls.filter((call) => call.elementColors).length).toBeGreaterThan(0)); + const donor = rendererCalls.filter((call) => call.compoundId === 'cpd00012').at(-1)?.elementColors as Record; + const product = rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)?.elementColors as Record; + expect(donor.P).toBe(product.P); + expect(container.textContent).toContain('atoms split across multiple products'); + expect(container.textContent).toContain('the corresponding atoms could not be resolved'); + }); + + it('keeps legacy atom colours and hides new mapping affordances without pairs', async () => { + renderEquation({ atomMapping: { cpd00001: { 0: '#123456' } }, atomMappingConfidence: 'clean' }); + await waitFor(() => expect(rendererCalls.length).toBeGreaterThan(0)); + expect(rendererCalls.find((call) => call.compoundId === 'cpd00001')?.atomColors).toEqual({ 0: '#123456' }); + expect(document.body.textContent).not.toContain('Atom mapping'); + expect(document.body.textContent).not.toContain('clean'); + }); + + it('settles inventory effects without a maximum-depth update error', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { container } = renderEquation({ atomMappingPairs: pairs }); + await waitFor(() => expect(container.textContent).toContain('Atom mapping')); + expect(error.mock.calls.flat().join(' ')).not.toContain('Maximum update depth'); + error.mockRestore(); + }); + + it('renders no bordered card surface around a compound', async () => { + const { container } = renderEquation(); + await waitFor(() => expect(container.querySelector('[aria-label^="Chemical equation:"]')).toBeTruthy()); + const equation = container.querySelector('[aria-label^="Chemical equation:"]'); + expect(equation?.querySelectorAll('.MuiCard-root')).toHaveLength(0); + expect(equation?.querySelectorAll('.MuiPaper-root')).toHaveLength(0); + }); + + it('renders a plus between same-side compounds and exactly one reaction operator', async () => { + const { container } = renderEquation({ equation: 'cpd00001[c] + cpd00012[c] => cpd00009[c] + cpd00012[c]' }); + await waitFor(() => expect(container.querySelector('[aria-label^="Chemical equation:"]')?.textContent).toContain('⇒')); + const equation = container.querySelector('[aria-label^="Chemical equation:"]'); + const pluses = Array.from(equation?.querySelectorAll('h6') ?? []).filter((node) => node.textContent === '+'); + const arrows = Array.from(equation?.querySelectorAll('h5') ?? []).filter((node) => node.textContent === '⇒'); + expect(pluses).toHaveLength(2); + expect(arrows).toHaveLength(1); + expect([...pluses, ...arrows].every((node) => node.getAttribute('aria-hidden') === 'true')).toBe(true); + }); + + it('renders the compound ID and formula as secondary text beneath the prominent name', async () => { + const { container, getByText } = renderEquation(); + await waitFor(() => expect(getByText('Phosphate donor')).toBeTruthy()); + const caption = Array.from(container.querySelectorAll('.MuiTypography-caption')) + .find((node) => node.textContent?.includes('cpd00012') && node.textContent.includes('H4O7P2')); + const name = getByText('Phosphate donor', { selector: 'a p' }); + expect(caption).toBeTruthy(); + expect(caption?.textContent).toContain('cpd00012'); + expect(caption?.textContent).toContain('H4O7P2'); + expect(name).not.toBe(caption); + }); + + it('renders a compound with three or fewer heavy atoms as a text token instead of a structure', async () => { + const { container } = renderEquation(); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00012"]')).toBeTruthy()); + expect(container.querySelector('[data-testid="structure-cpd00001"]')).toBeNull(); + expect(container.querySelector('a[href="/biochem/compounds/cpd00001"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="structure-cpd00012"]')).toBeTruthy(); + }); + + it('orders drawn structures before simple ion text tokens on the same side', async () => { + const { container, getByText } = renderEquation({ equation: 'cpd00001[c] + cpd00012[c] => cpd00009[c]' }); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00012"]')).toBeTruthy()); + const equation = container.querySelector('[aria-label^="Chemical equation:"]'); + const structure = container.querySelector('[data-testid="structure-cpd00012"]'); + const water = getByText('Water', { selector: 'a p' }); + expect(structure && water && Boolean(structure.compareDocumentPosition(water) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + expect(equation?.textContent).toContain('Phosphate donor'); + }); +}); From 8766d08b7dedaa0d35f25d2d07d1c4591e584b9f Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 13:45:15 -0500 Subject: [PATCH 14/34] refactor(biochem): wire mapping colours into the reaction page and drop the flat atom-flow diagram The reaction detail page now parses its atom-mapping entries once and hands the pairs to the structure equation, which renders them as coloured atom and bond groups. The old flat atom-flow diagram duplicated that information in a weaker form and is removed; the raw mapping list stays as secondary detail. --- .../biochem/reactions/[id]/page.tsx | 28 +++--- components/ui/AtomFlowDiagram.tsx | 86 ------------------- .../unit/components/AtomFlowDiagram.test.tsx | 48 ----------- 3 files changed, 15 insertions(+), 147 deletions(-) delete mode 100644 components/ui/AtomFlowDiagram.tsx delete mode 100644 tests/unit/components/AtomFlowDiagram.test.tsx diff --git a/app/(reference-data)/biochem/reactions/[id]/page.tsx b/app/(reference-data)/biochem/reactions/[id]/page.tsx index 77fbed65..39a8d436 100644 --- a/app/(reference-data)/biochem/reactions/[id]/page.tsx +++ b/app/(reference-data)/biochem/reactions/[id]/page.tsx @@ -16,7 +16,6 @@ import ChemicalEquation from '@/components/ui/ChemicalEquation'; import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation'; import ThermodynamicsTable from '@/components/ui/ThermodynamicsTable'; import AtomMappingSummary from '@/components/ui/AtomMappingSummary'; -import AtomFlowDiagram from '@/components/ui/AtomFlowDiagram'; import { normalizeAtomMapping, parseAtomMappings } from '@/lib/utils/atomMapping'; import { directionAgreementFromRecords, @@ -380,8 +379,23 @@ export default function ReactionDetailPage() { )} + {atomPairs.length > 0 && ( + + + Raw mapping entries are available as supporting detail. + + + + )} @@ -507,18 +521,6 @@ export default function ReactionDetailPage() { )} - {atomPairs.length > 0 && ( - - - - - - - )} diff --git a/components/ui/AtomFlowDiagram.tsx b/components/ui/AtomFlowDiagram.tsx deleted file mode 100644 index 81b82a45..00000000 --- a/components/ui/AtomFlowDiagram.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import Link from 'next/link'; -import { summarizeAtomFlows, type AtomMappingPair } from '@/lib/utils/atomMapping'; - -export interface AtomFlowDiagramProps { - pairs: readonly AtomMappingPair[]; -} - -const LEFT_X = 120; -const RIGHT_X = 420; -const TOP_Y = 36; -const ROW_HEIGHT = 44; - -export default function AtomFlowDiagram({ pairs }: AtomFlowDiagramProps): React.ReactElement | null { - const flows = useMemo(() => summarizeAtomFlows(pairs), [pairs]); - const groupedEdges = useMemo( - () => new Set( - pairs - .filter((pair) => pair.hasSymmetryGroup) - .map((pair) => `${pair.left.compoundId}>${pair.right.compoundId}`), - ), - [pairs], - ); - - if (flows.length === 0) return null; - - const fromIds = Array.from(new Set(flows.map((flow) => flow.from))); - const toIds = Array.from(new Set(flows.map((flow) => flow.to))); - const fromY = new Map(fromIds.map((id, index) => [id, TOP_Y + index * ROW_HEIGHT])); - const toY = new Map(toIds.map((id, index) => [id, TOP_Y + index * ROW_HEIGHT])); - const height = TOP_Y * 2 + (Math.max(fromIds.length, toIds.length) - 1) * ROW_HEIGHT; - const largestTotal = Math.max(...flows.map((flow) => flow.total)); - const strokeWidth = (total: number) => - largestTotal === 0 ? 1.5 : 1.5 + ((total / largestTotal) * 6.5); - - return ( -
- - {flows.map((flow) => { - const breakdown = Array.from(flow.byElement.entries()) - .map(([element, count]) => `${element} ${count}`) - .join(', '); - return ( - ${flow.to}`} - x1={LEFT_X} - y1={fromY.get(flow.from)} - x2={RIGHT_X} - y2={toY.get(flow.to)} - stroke="#00838f" - strokeOpacity="0.65" - strokeWidth={strokeWidth(flow.total)} - strokeDasharray={groupedEdges.has(`${flow.from}>${flow.to}`) ? '6 4' : undefined} - > - {`${flow.from} to ${flow.to}: ${flow.total} atoms (${breakdown})`} - - ); - })} - {fromIds.map((id) => ( - - - {id} - - - ))} - {toIds.map((id) => ( - - - {id} - - - ))} - - Counts are mapped atoms per compound pair; individual atom positions are not shown. - {groupedEdges.size > 0 && A dashed edge carries at least one symmetry-grouped mapping.} -
- ); -} diff --git a/tests/unit/components/AtomFlowDiagram.test.tsx b/tests/unit/components/AtomFlowDiagram.test.tsx deleted file mode 100644 index 259cebfd..00000000 --- a/tests/unit/components/AtomFlowDiagram.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render } from '@testing-library/react'; -import AtomFlowDiagram from '@/components/ui/AtomFlowDiagram'; -import { parseAtomMappings } from '@/lib/utils/atomMapping'; - -const RXN00001_PAIRS = parseAtomMappings([ - 'rxn00001 cpd00001:O#1=cpd00009:O#2', - 'rxn00001 cpd00012:O#1=cpd00009:O#1', - 'rxn00001 cpd00012:O#2=cpd00009:O#2', - 'rxn00001 cpd00012:O#3=cpd00009:O#3', - 'rxn00001 cpd00012:O#4=cpd00009:O#3', - 'rxn00001 cpd00012:O#5=cpd00009:O#1', - 'rxn00001 cpd00012:O#6=cpd00009:O#4', - 'rxn00001 cpd00012:O#7=cpd00009:O#4', - 'rxn00001 cpd00012:P#1=cpd00009:P#1', - 'rxn00001 cpd00012:P#2=cpd00009:P#1', -]); - -describe('AtomFlowDiagram', () => { - it('renders nothing for an empty pair list', () => { - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); - - it('renders compound ids, totals, links, and element breakdowns', () => { - const { container, getByText } = render(); - - expect(container.textContent).toContain('cpd00001'); - expect(container.textContent).toContain('cpd00009'); - expect(container.querySelector('title')?.textContent).toContain('1 atoms (O 1)'); - expect(container.querySelectorAll('title')[1]?.textContent).toContain('9 atoms (O 7, P 2)'); - expect(getByText('cpd00009').closest('a')?.getAttribute('href')).toBe( - '/biochem/compounds/cpd00009', - ); - expect(container.querySelector('line')?.getAttribute('stroke-dasharray')).toBeNull(); - expect(container.textContent).not.toContain('A dashed edge carries'); - }); - - it('uses a dashed edge and legend for symmetry-grouped mappings', () => { - const pairs = parseAtomMappings(['cpd00001:(O#1;O#2)=cpd00009:O#1']); - const { container } = render(); - - expect(container.querySelector('line')?.getAttribute('stroke-dasharray')).toBe('6 4'); - expect(container.textContent).toContain( - 'A dashed edge carries at least one symmetry-grouped mapping.', - ); - }); -}); From 8c259f33839bcbab40c974ff083b60c9722e25c4 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 13:45:26 -0500 Subject: [PATCH 15/34] feat(release): prepare 3.2.0 release Records the reaction atom-mapping canvas: open continuous structure layout, element-block mapping colours carried across reactant and product, an explicit unmappable disclosure, and removal of the flat atom-flow diagram. --- CHANGELOG.md | 20 ++++++++++++++++++++ VERSION.md | 2 +- package.json | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4ff845..7a2104d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - TBD +### Known Issues +- RAST MS FBA not working +- PATRIC-only model submission +- Workspace write operations limited + +### Expected Behaviors +- Models/Media differ between RAST and PATRIC (intentional system design) + +--- + +## [3.2.0] - 2026-08-20 + +### Added +- Reaction structure equations now use an open, continuous canvas with prominent common names, secondary IDs, formulas and charges; plain `+` and direction operators; text-rendered simple ions; and compound-page links +- Reaction atom mappings now colour atoms and bonds by mapped group across reactants and products, with a legend that discloses mappings that cannot safely be coloured +- Mapping colours are applied only to fully covered, mutually mapped compound-element blocks, never by treating InChI canonical-order `#N` indices as renderer atom indices + +### Changed +- Replaced the flat reaction atom-flow diagram with the structure canvas; the raw mapping list remains available as secondary detail + ### Added - Compound and reaction detail pages now list every thermodynamics record returned by the upgraded Solr schema, one row per source with energy, error and (for reactions) direction operator - Compound detail page now shows all pKa and pKb values instead of only the first diff --git a/VERSION.md b/VERSION.md index fd2a0186..944880fa 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -3.1.0 +3.2.0 diff --git a/package.json b/package.json index 9db3604f..184c5776 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modelseed-ui", - "version": "3.1.0", + "version": "3.2.0", "private": true, "scripts": { "predev": "node scripts/sync-version-from-env.mjs", From 758bdb5e64dfe692f4f0dcde6886e81937018600 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 13:54:44 -0500 Subject: [PATCH 16/34] fix(biochem): tighten the reaction canvas after UI review - do not paint multi-element simple-ion text tokens one arbitrary mapping colour; leave them uncoloured and disclose the ambiguity in the legend - drop the border and background chrome from the MoleculeRenderer png and hidden placeholders so a missing structure no longer reintroduces a box inside the open canvas - surface a plain caption when compound details fail to load, so a failed fetch is distinguishable from genuinely absent data - pin the degradation cases: missing name, missing SMILES, zero/negative charge, stoichiometry of one, fetch error, multi-element simple ion --- components/ui/MoleculeRenderer.tsx | 4 -- components/ui/ReactionStructureEquation.tsx | 10 ++- .../ReactionStructureEquation.test.tsx | 68 ++++++++++++++++++- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/components/ui/MoleculeRenderer.tsx b/components/ui/MoleculeRenderer.tsx index 2a1406ac..d4bfd554 100644 --- a/components/ui/MoleculeRenderer.tsx +++ b/components/ui/MoleculeRenderer.tsx @@ -200,10 +200,8 @@ export default function MoleculeRenderer({ height={height} style={{ objectFit: 'contain', - border: '1px solid #e0e0e0', borderRadius: 4, padding: 4, - background: '#fff', }} onError={() => setState('hidden')} /> @@ -220,9 +218,7 @@ export default function MoleculeRenderer({ display: 'flex', alignItems: 'center', justifyContent: 'center', - border: '1px dashed #cbd5e1', borderRadius: 4, - background: '#f8fafc', padding: 8, boxSizing: 'border-box', }} diff --git a/components/ui/ReactionStructureEquation.tsx b/components/ui/ReactionStructureEquation.tsx index 1f8ff7ca..7f74c6b3 100644 --- a/components/ui/ReactionStructureEquation.tsx +++ b/components/ui/ReactionStructureEquation.tsx @@ -113,8 +113,9 @@ function CompoundColumn({ token, data, inventory, atomColors, elementColors, map const simple = isSimpleIon(inventory); const label = data?.name || token.id; const metadata = [token.id, data?.formula, formatCharge(data?.charge)].filter(Boolean).join(' · '); + const elementColorValues = Object.values(elementColors ?? {}); const contents = simple ? ( - + {label}{formatCharge(data?.charge) && {formatCharge(data?.charge)}} ) : ( @@ -183,7 +184,7 @@ export default function ReactionStructureEquation({ equation, reversibility, ato const allIds = useMemo(() => [...parsed.reactants, ...parsed.products].map((token) => token.id), [parsed]); const uniqueCompoundIds = useMemo(() => Array.from(new Set(allIds)), [allIds]); const compoundIdsKey = useMemo(() => [...uniqueCompoundIds].sort().join(','), [uniqueCompoundIds]); - const { data: compoundMap, isLoading } = useQuery({ + const { data: compoundMap, isLoading, error } = useQuery({ queryKey: ['reaction-structure-compounds', compoundIdsKey], queryFn: () => getCompoundsForReaction(uniqueCompoundIds), enabled: uniqueCompoundIds.length > 0, staleTime: 5 * 60 * 1000, }); @@ -203,6 +204,8 @@ export default function ReactionStructureEquation({ equation, reversibility, ato const useElementColors = pairs.length > 0; const plan = useMemo(() => buildAtomMappingColorPlan(pairs, inventories), [pairs, inventories]); const reasons = useMemo(() => Array.from(new Set(plan.unmappable.map((block) => block.reason).filter((reason): reason is UnmappableReason => Boolean(reason)))).map((reason) => REASON_TEXT[reason]), [plan]); + const hasMultiElementSimpleIon = useMemo(() => uniqueCompoundIds.some((compoundId) => isSimpleIon(inventories[compoundId]) + && Object.keys(elementColorsForCompound(plan, compoundId)).length > 1), [uniqueCompoundIds, inventories, plan]); if (!equation) return null; if (isLoading) return {allIds.map((id, index) => )}; @@ -213,6 +216,7 @@ export default function ReactionStructureEquation({ equation, reversibility, ato + {error && Compound details could not be loaded.} {useElementColors && {(plan.colorableCount > 0 || atomMappingConfidence || atomMappingHasSymmetryGroups) && {plan.colorableCount > 0 && Atom mapping} @@ -225,7 +229,7 @@ export default function ReactionStructureEquation({ equation, reversibility, ato {entry.element}: {entry.compoundIds.join(' and ')} )} } - {reasons.length > 0 && Some atoms could not be unambiguously mapped and therefore are not coloured: {reasons.join('; ')}.} + {(reasons.length > 0 || hasMultiElementSimpleIon) && Some atoms could not be unambiguously mapped and therefore are not coloured: {[...reasons, ...(hasMultiElementSimpleIon ? ['simple ions with multiple independently mapped elements'] : [])].join('; ')}.} } ; } diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx index dab2ce9d..c2649dc9 100644 --- a/tests/unit/components/ReactionStructureEquation.test.tsx +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -2,13 +2,15 @@ import { describe, expect, it, vi } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { parseAtomMappings } from '@/lib/utils/atomMapping'; +import { getCompoundsForReaction, type Compound } from '@/lib/api/biochem'; import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation'; const rendererCalls: Array> = []; +const compound = (data: Partial) => data as Compound; const compounds = new Map([ - ['cpd00001', { name: 'Water', smiles: 'O', formula: 'H2O', charge: 0 }], - ['cpd00012', { name: 'Phosphate donor', smiles: 'OP(=O)(O)O', formula: 'H4O7P2', charge: -2 }], - ['cpd00009', { name: 'Phosphate', smiles: 'OP(=O)(O)O', formula: 'H3O4P', charge: -1 }], + ['cpd00001', compound({ name: 'Water', smiles: 'O', formula: 'H2O', charge: 0 })], + ['cpd00012', compound({ name: 'Phosphate donor', smiles: 'OP(=O)(O)O', formula: 'H4O7P2', charge: -2 })], + ['cpd00009', compound({ name: 'Phosphate', smiles: 'OP(=O)(O)O', formula: 'H3O4P', charge: -1 })], ]); vi.mock('@/lib/api/biochem', () => ({ getCompoundsForReaction: vi.fn(async () => compounds) })); @@ -17,8 +19,10 @@ vi.mock('@/components/ui/MoleculeRenderer', () => ({ rendererCalls.push(props); const inventories: Record> = { cpd00001: { O: 1, H: 2 }, cpd00012: { P: 2, O: 7, H: 4 }, cpd00009: { P: 1, O: 4, H: 3 }, + cpd00002: { C: 1, O: 2 }, cpd00003: { C: 1, O: 2 }, }; (props.onInventory as ((inventory: Record) => void) | undefined)?.(inventories[props.compoundId as string] ?? { C: 4 }); + if (!props.smiles) return
Compound image unavailable
; return
; }, })); @@ -124,4 +128,62 @@ describe('ReactionStructureEquation', () => { expect(structure && water && Boolean(structure.compareDocumentPosition(water) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); expect(equation?.textContent).toContain('Phosphate donor'); }); + + it('uses the compound ID when a compound name is missing', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd00001', compound({ smiles: 'O', formula: 'H2O', charge: 0 })], + ['cpd00012', compound({ name: 'Phosphate donor', smiles: 'OP(=O)(O)O', formula: 'H4O7P2', charge: -2 })], + ['cpd00009', compound({ name: 'Phosphate', smiles: 'OP(=O)(O)O', formula: 'H3O4P', charge: -1 })], + ])); + const { container, getByText } = renderEquation(); + await waitFor(() => expect(getByText('cpd00001', { selector: 'a p' })).toBeTruthy()); + expect(container.textContent).not.toContain('undefined'); + }); + + it('renders an unboxed placeholder when a compound SMILES is missing', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd00001', compound({ name: 'Water', smiles: 'O', formula: 'H2O', charge: 0 })], + ['cpd00012', compound({ name: 'Phosphate donor', formula: 'H4O7P2', charge: -2 })], + ['cpd00009', compound({ name: 'Phosphate', smiles: 'OP(=O)(O)O', formula: 'H3O4P', charge: -1 })], + ])); + const { getByTestId, getByText } = renderEquation(); + await waitFor(() => expect(getByText('Compound image unavailable')).toBeTruthy()); + expect(getByTestId('structure-cpd00012').getAttribute('style')).not.toContain('border'); + }); + + it('formats zero and negative compound charges as intended', async () => { + const { container } = renderEquation(); + await waitFor(() => expect(container.textContent).toContain('cpd00012')); + const captions = Array.from(container.querySelectorAll('.MuiTypography-caption')).map((node) => node.textContent); + expect(captions.some((caption) => caption === 'cpd00001 · H2O')).toBe(true); + expect(captions.some((caption) => caption?.includes('cpd00012 · H4O7P2 · 2-'))).toBe(true); + }); + + it('does not render a stoichiometry coefficient of one', async () => { + const { container } = renderEquation({ equation: '1 cpd00001[c] + cpd00012[c] => cpd00009[c]' }); + await waitFor(() => expect(container.querySelector('[aria-label^="Chemical equation:"]')).toBeTruthy()); + const waterLink = container.querySelector('a[href="/biochem/compounds/cpd00001"]'); + expect(waterLink?.parentElement?.previousElementSibling?.textContent).not.toBe('1'); + }); + + it('explains when compound details could not be loaded', async () => { + vi.mocked(getCompoundsForReaction).mockRejectedValueOnce(new Error('fetch failed')); + const { getByText } = renderEquation(); + await waitFor(() => expect(getByText('Compound details could not be loaded.')).toBeTruthy()); + }); + + it('leaves multi-element simple ions uncoloured and explains the ambiguity', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd00002', compound({ name: 'Carbon dioxide', smiles: 'O=C=O', formula: 'CO2', charge: 0 })], + ['cpd00003', compound({ name: 'Carbon dioxide product', smiles: 'O=C=O', formula: 'CO2', charge: 0 })], + ])); + const multiElementPairs = parseAtomMappings([ + 'cpd00002:C#1=cpd00003:C#1', + 'cpd00002:(O#1;O#2)=cpd00003:(O#1;O#2)', + ]); + const { getByText } = renderEquation({ equation: 'cpd00002[c] => cpd00003[c]', atomMappingPairs: multiElementPairs }); + await waitFor(() => expect(getByText(/simple ions with multiple independently mapped elements/)).toBeTruthy()); + expect(getByText('Carbon dioxide', { selector: 'a p' }).getAttribute('style')).toBeNull(); + }); + }); From b514e5f9d305c4328d5b1468a57c6df17b205c49 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 15:04:40 -0500 Subject: [PATCH 17/34] feat(biochem): colour merged atom-mapping groups instead of discarding them The atom-mapping colour plan required strict pairwise reciprocity: a (compound, element) block whose atoms mapped to more than one counterpart compound was marked `multiple-destinations` and dropped, which also disqualified its otherwise-valid partners. On rxn00002 that silently removed every oxygen block, because CO2 oxygen is fed by both water and allophanate. Group blocks of the same element into connected components and colour the whole component, distinguishing `one-to-one` from `merged` correspondence and naming the members that could not be coloured. Set-level correspondence is proven by the data; atom-level correspondence still is not, so colour is still asserted only for a block whose mapped index count equals its structural atom count. Add `lib/utils/chemicalFormula.ts` so element inventories can be derived from a compound formula instead of only from a rendered structure. --- lib/utils/atomMappingColors.ts | 136 ++++++++++++++------- lib/utils/chemicalFormula.ts | 50 ++++++++ tests/unit/utils/atomMappingColors.test.ts | 66 ++++++---- tests/unit/utils/chemicalFormula.test.ts | 55 +++++++++ 4 files changed, 241 insertions(+), 66 deletions(-) create mode 100644 lib/utils/chemicalFormula.ts create mode 100644 tests/unit/utils/chemicalFormula.test.ts diff --git a/lib/utils/atomMappingColors.ts b/lib/utils/atomMappingColors.ts index 658c2f7f..b5ace27f 100644 --- a/lib/utils/atomMappingColors.ts +++ b/lib/utils/atomMappingColors.ts @@ -7,8 +7,10 @@ import type { AtomMappingPair, AtomRef } from './atomMapping'; * 1-based per-element, per-compound indices in InChI canonical atom order, * not SMILES or RDKit atom indices. RDKit MinimalLib cannot recover that * order, so this module never emits or accepts atom-index-to-colour mappings: - * an entire (compound, element) block is coloured only after full coverage - * and mutuality make that scientifically safe. + * an entire (compound, element) block is coloured only after its mapped index + * count equals its structural atom count. Colours never assert atom-index-level + * correspondence; merged components assert only set-level correspondence among + * their fully covered member blocks. */ export type ElementInventory = Readonly>; @@ -16,17 +18,20 @@ export type ElementInventory = Readonly>; export type UnmappableReason = | 'no-mapping' | 'element-mismatch' - | 'multiple-destinations' | 'structure-unknown' | 'partial-coverage' | 'counterpart-unresolved'; +export type BlockGroupKind = 'one-to-one' | 'merged'; + export interface ElementBlockAssignment { readonly compoundId: string; readonly element: string; readonly colorable: boolean; readonly color?: string; readonly groupId?: string; + readonly kind?: BlockGroupKind; + readonly groupCompoundIds?: readonly string[]; readonly counterpartCompoundIds: readonly string[]; readonly mappedIndexCount: number; readonly structureAtomCount?: number; @@ -38,6 +43,8 @@ export interface AtomMappingColorLegendEntry { readonly color: string; readonly element: string; readonly compoundIds: readonly string[]; + readonly kind: BlockGroupKind; + readonly uncoloredCompoundIds: readonly string[]; } export interface AtomMappingColorPlan { @@ -123,18 +130,13 @@ function stateFor( ): BlockState { const counterpartCompoundIds = Array.from(block.counterparts).sort(); const structureAtomCount = readInventory(inventories, block.compoundId, block.element); - let basicReason: UnmappableReason | undefined; - - if (Array.from(block.counterpartElements).some((element) => element !== block.element)) { - basicReason = 'element-mismatch'; - } else if (counterpartCompoundIds.length !== 1) { - basicReason = 'multiple-destinations'; - } else if (structureAtomCount === undefined) { - basicReason = 'structure-unknown'; - } else if (block.indices.size !== structureAtomCount) { - basicReason = 'partial-coverage'; - } - + const basicReason = Array.from(block.counterpartElements).some((element) => element !== block.element) + ? 'element-mismatch' as const + : structureAtomCount === undefined + ? 'structure-unknown' as const + : block.indices.size !== structureAtomCount + ? 'partial-coverage' as const + : undefined; return { block, counterpartCompoundIds, structureAtomCount, basicReason }; } @@ -147,58 +149,102 @@ export function buildAtomMappingColorPlan( for (const pair of Array.isArray(pairs) ? pairs : []) { const leftAtoms = Array.isArray(pair?.leftAtoms) ? pair.leftAtoms : []; const rightAtoms = Array.isArray(pair?.rightAtoms) ? pair.rightAtoms : []; - for (const ref of leftAtoms) { - if (isAtomRef(ref)) accumulateBlock(blocks, ref, rightAtoms); - } - for (const ref of rightAtoms) { - if (isAtomRef(ref)) accumulateBlock(blocks, ref, leftAtoms); - } + for (const ref of leftAtoms) if (isAtomRef(ref)) accumulateBlock(blocks, ref, rightAtoms); + for (const ref of rightAtoms) if (isAtomRef(ref)) accumulateBlock(blocks, ref, leftAtoms); } const states = new Map(); for (const [key, block] of blocks) states.set(key, stateFor(block, inventories)); - - const colorableGroups = new Map(); + const neighbors = new Map>(); + for (const key of states.keys()) neighbors.set(key, new Set()); for (const [key, state] of states) { - if (state.basicReason || state.counterpartCompoundIds.length !== 1) continue; - const counterpartCompoundId = state.counterpartCompoundIds[0]; - const counterpartKey = blockKey(counterpartCompoundId, state.block.element); - const counterpart = states.get(counterpartKey); - if (!counterpart || counterpart.basicReason || counterpart.counterpartCompoundIds.length !== 1 - || counterpart.counterpartCompoundIds[0] !== state.block.compoundId) continue; + if (state.basicReason === 'element-mismatch') continue; + for (const compoundId of state.counterpartCompoundIds) { + const counterpartKey = blockKey(compoundId, state.block.element); + if (states.has(counterpartKey)) { + neighbors.get(key)?.add(counterpartKey); + neighbors.get(counterpartKey)?.add(key); + } + } + } - const groupId = [key, counterpartKey].sort().join('='); - colorableGroups.set(groupId, { - element: state.block.element, - compoundIds: [state.block.compoundId, counterpartCompoundId].sort(), + const components: string[][] = []; + const componentForKey = new Map(); + for (const key of Array.from(states.keys()).sort((left, right) => left.localeCompare(right))) { + if (componentForKey.has(key)) continue; + const component: string[] = []; + const queue = [key]; + componentForKey.set(key, components.length); + while (queue.length > 0) { + const current = queue.shift()!; + component.push(current); + for (const neighbor of neighbors.get(current) ?? []) { + if (!componentForKey.has(neighbor)) { + componentForKey.set(neighbor, components.length); + queue.push(neighbor); + } + } + } + components.push(component.sort((left, right) => left.localeCompare(right))); + } + + const componentGroups = new Map(); + for (const [index, component] of components.entries()) { + const members = component.map((key) => states.get(key)!); + const candidates = members.filter((state) => !state.basicReason); + const compoundIds = Array.from(new Set(members.map((state) => state.block.compoundId))) + .sort((left, right) => left.localeCompare(right)); + const colorable = candidates.length >= 2 && compoundIds.length >= 2; + componentGroups.set(index, { + groupId: component.join('='), + element: members[0].block.element, + compoundIds, + uncoloredCompoundIds: Array.from(new Set(members.filter((state) => state.basicReason) + .map((state) => state.block.compoundId))).sort((left, right) => left.localeCompare(right)), + kind: component.length === 2 && members.every((state) => state.counterpartCompoundIds.length === 1) + ? 'one-to-one' : 'merged', + colorable, }); } const groupColors = new Map(); - const legend = Array.from(colorableGroups.entries()) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([groupId, group], index) => { + const legend = Array.from(componentGroups.values()).filter((group) => group.colorable) + .sort((left, right) => left.groupId.localeCompare(right.groupId)) + .map((group, index) => { const color = MAPPING_PALETTE[index % MAPPING_PALETTE.length]; - groupColors.set(groupId, color); - return { groupId, color, element: group.element, compoundIds: group.compoundIds }; + groupColors.set(group.groupId, color); + return { + groupId: group.groupId, + color, + element: group.element, + compoundIds: group.compoundIds, + kind: group.kind, + uncoloredCompoundIds: group.uncoloredCompoundIds, + }; }); const assignments = Array.from(states.entries()) .sort(([, left], [, right]) => left.block.compoundId.localeCompare(right.block.compoundId) || left.block.element.localeCompare(right.block.element)) .map(([key, state]): ElementBlockAssignment => { - const counterpartKey = state.counterpartCompoundIds.length === 1 - ? blockKey(state.counterpartCompoundIds[0], state.block.element) - : undefined; - const groupId = counterpartKey ? [key, counterpartKey].sort().join('=') : undefined; - const color = groupId ? groupColors.get(groupId) : undefined; - if (color && groupId) { + const group = componentGroups.get(componentForKey.get(key)!); + const color = group?.colorable ? groupColors.get(group.groupId) : undefined; + if (color && group && !state.basicReason) { return { compoundId: state.block.compoundId, element: state.block.element, colorable: true, color, - groupId, + groupId: group.groupId, + kind: group.kind, + groupCompoundIds: group.compoundIds, counterpartCompoundIds: state.counterpartCompoundIds, mappedIndexCount: state.block.indices.size, structureAtomCount: state.structureAtomCount, diff --git a/lib/utils/chemicalFormula.ts b/lib/utils/chemicalFormula.ts new file mode 100644 index 00000000..0f13f97f --- /dev/null +++ b/lib/utils/chemicalFormula.ts @@ -0,0 +1,50 @@ +interface FormulaParseResult { + readonly inventory: Record; + readonly isParsable: boolean; +} + +function parseFormula(formula: string | null | undefined): FormulaParseResult { + if ( + typeof formula !== 'string' || + !formula.trim() || + !/^[A-Za-z0-9]+$/.test(formula) + ) { + return { inventory: {}, isParsable: false }; + } + + const inventory: Record = {}; + let position = 0; + const token = /([A-Z][a-z]?)(\d*)/g; + let match: RegExpExecArray | null; + while ((match = token.exec(formula)) !== null) { + if (match.index !== position) return { inventory: {}, isParsable: false }; + const count = match[2] ? Number.parseInt(match[2], 10) : 1; + if (!Number.isFinite(count)) return { inventory: {}, isParsable: false }; + inventory[match[1]] = (inventory[match[1]] ?? 0) + count; + position = token.lastIndex; + } + + return position === formula.length + ? { inventory, isParsable: true } + : { inventory: {}, isParsable: false }; +} + +/** Parse a simple molecular formula into its element inventory. */ +export function parseFormulaInventory( + formula: string | null | undefined, +): Record { + return parseFormula(formula).inventory; +} + +/** Whether a molecular formula was completely consumed by the simple formula parser. */ +export function isParsableFormula(formula: string | null | undefined): boolean { + return parseFormula(formula).isParsable; +} + +/** Count non-hydrogen atoms in a simple molecular formula. */ +export function heavyAtomCount(formula: string | null | undefined): number { + return Object.entries(parseFormulaInventory(formula)).reduce( + (total, [element, count]) => total + (element === 'H' ? 0 : count), + 0, + ); +} diff --git a/tests/unit/utils/atomMappingColors.test.ts b/tests/unit/utils/atomMappingColors.test.ts index a53251c1..ccebe828 100644 --- a/tests/unit/utils/atomMappingColors.test.ts +++ b/tests/unit/utils/atomMappingColors.test.ts @@ -19,27 +19,30 @@ function pairs(entries: readonly string[]): AtomMappingPair[] { } describe('buildAtomMappingColorPlan', () => { - it('safely colours only the mutually covered phosphorus blocks in the real rxn00001 payload', () => { - const plan = buildAtomMappingColorPlan(pairs(REAL_ENTRIES), { - cpd00001: { O: 1 }, - cpd00012: { P: 2, O: 7 }, - cpd00009: { P: 1, O: 4 }, + it('colours merged components in the real rxn00002 payload', () => { + const plan = buildAtomMappingColorPlan(pairs([ + 'cpd00001:O#1=cpd00011:(O#1;O#2)', + 'cpd00742:(O#2;O#3)=cpd00011:(O#1;O#2)', + 'cpd00742:C#1=cpd00011:C#1', + 'cpd00742:C#2=cpd00011:C#1', + 'cpd00742:N#1=cpd00013:N#1', + 'cpd00742:N#2=cpd00013:N#1', + 'cpd00742:O#1=cpd00011:(O#1;O#2)', + ]), { + cpd00001: { O: 1, H: 2 }, + cpd00011: { C: 1, O: 2 }, + cpd00013: { N: 1, H: 4 }, + cpd00742: { C: 2, H: 3, N: 2, O: 3 }, }); - const leftP = blockAssignment(plan, 'cpd00012', 'P'); - const rightP = blockAssignment(plan, 'cpd00009', 'P'); - - expect(leftP.colorable).toBe(true); - expect(rightP.colorable).toBe(true); - // The parenthesized P group contributes both distinct mapped references. - expect(leftP.mappedIndexCount).toBe(2); - expect(leftP.groupId).toBe(rightP.groupId); - expect(leftP.color).toBe(MAPPING_PALETTE[0]); - expect(rightP.color).toBe(MAPPING_PALETTE[0]); - expect(blockAssignment(plan, 'cpd00009', 'O').reason).toBe('multiple-destinations'); - expect(blockAssignment(plan, 'cpd00001', 'O').reason).toBe('counterpart-unresolved'); - expect(blockAssignment(plan, 'cpd00012', 'O').reason).toBe('counterpart-unresolved'); - expect(plan.colorableCount).toBe(2); - expect(plan.legend).toHaveLength(1); + expect(plan.legend).toHaveLength(3); + expect(plan.legend.map((entry) => entry.element).sort()).toEqual(['C', 'N', 'O']); + expect(plan.legend.find((entry) => entry.element === 'O')).toMatchObject({ + kind: 'merged', compoundIds: ['cpd00001', 'cpd00011', 'cpd00742'], + }); + expect(plan.legend.filter((entry) => entry.element !== 'O').map((entry) => entry.kind)) + .toEqual(['one-to-one', 'one-to-one']); + expect(plan.unmappable).toEqual([]); + expect(new Set(plan.legend.map((entry) => entry.color)).size).toBe(3); }); it('reports partial coverage when mapped indices do not cover the rendered structure', () => { @@ -67,6 +70,25 @@ describe('buildAtomMappingColorPlan', () => { expect(blockAssignment(plan, 'cpd00002', 'P').reason).toBe('element-mismatch'); }); + it('leaves a singleton component counterpart-unresolved', () => { + const plan = buildAtomMappingColorPlan(pairs(['cpd00001:O#1=cpd00002:O#1']), { + cpd00001: { O: 1 }, + }); + expect(blockAssignment(plan, 'cpd00001', 'O').reason).toBe('counterpart-unresolved'); + }); + + it('terminates and colours a cyclic three-compound component', () => { + const plan = buildAtomMappingColorPlan(pairs([ + 'cpd00001:O#1=cpd00002:O#1', + 'cpd00002:O#1=cpd00003:O#1', + 'cpd00003:O#1=cpd00001:O#1', + ]), { cpd00001: { O: 1 }, cpd00002: { O: 1 }, cpd00003: { O: 1 } }); + expect(plan.legend).toHaveLength(1); + expect(plan.legend[0]).toMatchObject({ + kind: 'merged', compoundIds: ['cpd00001', 'cpd00002', 'cpd00003'], + }); + }); + it('synthesises no-mapping assignments for absent blocks', () => { const plan = buildAtomMappingColorPlan([], {}); expect(blockAssignment(plan, 'cpd00001', 'O')).toMatchObject({ @@ -102,7 +124,9 @@ describe('buildAtomMappingColorPlan', () => { const plan = buildAtomMappingColorPlan(pairs(REAL_ENTRIES), { cpd00001: { O: 1 }, cpd00012: { P: 2, O: 7 }, cpd00009: { P: 1, O: 4 }, }); - expect(elementColorsForCompound(plan, 'cpd00012')).toEqual({ P: MAPPING_PALETTE[0] }); + expect(elementColorsForCompound(plan, 'cpd00012')).toEqual({ + O: MAPPING_PALETTE[0], P: MAPPING_PALETTE[1], + }); expect(elementColorsForCompound(plan, 'missing')).toEqual({}); }); diff --git a/tests/unit/utils/chemicalFormula.test.ts b/tests/unit/utils/chemicalFormula.test.ts new file mode 100644 index 00000000..375b4b4c --- /dev/null +++ b/tests/unit/utils/chemicalFormula.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + heavyAtomCount, + isParsableFormula, + parseFormulaInventory, +} from '@/lib/utils/chemicalFormula'; + +describe('parseFormulaInventory', () => { + it('parses repeated symbols and implicit counts', () => { + expect(parseFormulaInventory('CH3CH3')).toEqual({ C: 2, H: 6 }); + expect(parseFormulaInventory('H2O')).toEqual({ H: 2, O: 1 }); + expect(parseFormulaInventory('R')).toEqual({ R: 1 }); + }); + + it('returns an empty inventory for unknown formula syntax', () => { + for (const formula of [ + null, + undefined, + '', + ' ', + 'C6H12O6(+)', + '*', + '2H2O', + 'cH4', + ]) { + expect(parseFormulaInventory(formula)).toEqual({}); + } + }); +}); + +describe('heavyAtomCount', () => { + it('excludes hydrogen and treats unknown formulas as empty', () => { + expect(heavyAtomCount('H')).toBe(0); + expect(heavyAtomCount('H2O')).toBe(1); + expect(heavyAtomCount('CO2')).toBe(3); + expect(heavyAtomCount('H4N')).toBe(1); + expect(heavyAtomCount('C2H3N2O3')).toBe(7); + expect(heavyAtomCount('C6H12O6(+)')).toBe(0); + }); +}); + +describe('isParsableFormula', () => { + it('distinguishes complete formula parses from unknown syntax', () => { + expect(isParsableFormula('H2O')).toBe(true); + expect(isParsableFormula('C2H3N2O3')).toBe(true); + expect(isParsableFormula('H')).toBe(true); + expect(isParsableFormula('R')).toBe(true); + expect(isParsableFormula('C6H12O6(+)')).toBe(false); + expect(isParsableFormula('2H2O')).toBe(false); + expect(isParsableFormula('')).toBe(false); + expect(isParsableFormula(' ')).toBe(false); + expect(isParsableFormula(null)).toBe(false); + expect(isParsableFormula(undefined)).toBe(false); + }); +}); From 37e115bae6ac945f1dd75b4eac22ca2254b169a2 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 15:04:50 -0500 Subject: [PATCH 18/34] fix(biochem): draw every reaction participant that has a structure Reaction participants were routed to a text token by a heuristic that counted non-hydrogen atoms in the RDKit-reported inventory and sent anything with three or fewer to text. On rxn00002 that left water, CO2 and ammonium as bare labels even though all three carry valid SMILES and all three are atom-mapped, so the mapping was invisible. The heuristic also ran off a render callback, so the decision arrived after first paint. Decide from the compound record instead: draw whenever SMILES is present and the formula is either absent, unparseable, or contains at least one heavy atom. Only genuinely heavy-atom-free species such as H+ stay textual, and they now keep their name, formula, charge and compound link. Seed element inventories from the formula so a species that never renders is no longer classified `structure-unknown`; stop tinting text tokens with an element colour and show a labelled colour-dot row instead; keep tokens in equation order; and state merged groups and uncoloured members in the legend. Every token holds its footprint with a skeleton while the compound query is in flight. --- components/ui/ReactionStructureEquation.tsx | 85 ++++++----- .../ReactionStructureEquation.test.tsx | 138 +++++++++++++----- 2 files changed, 151 insertions(+), 72 deletions(-) diff --git a/components/ui/ReactionStructureEquation.tsx b/components/ui/ReactionStructureEquation.tsx index 7f74c6b3..eba21c9e 100644 --- a/components/ui/ReactionStructureEquation.tsx +++ b/components/ui/ReactionStructureEquation.tsx @@ -10,6 +10,7 @@ import Skeleton from '@mui/material/Skeleton'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { getCompoundsForReaction } from '@/lib/api/biochem'; +import { heavyAtomCount, isParsableFormula, parseFormulaInventory } from '@/lib/utils/chemicalFormula'; import type { AtomMappingPair } from '@/lib/utils/atomMapping'; import { buildAtomMappingColorPlan, @@ -47,13 +48,17 @@ const EMPTY_MAP = new Map(); const REASON_TEXT: Record = { 'no-mapping': 'no mapping data', 'element-mismatch': 'element mismatch between sides', - 'multiple-destinations': 'atoms split across multiple products', 'structure-unknown': 'structure unavailable', 'partial-coverage': 'mapping covers only part of the structure', - 'counterpart-unresolved': 'the corresponding atoms could not be resolved', + 'counterpart-unresolved': 'no matching atoms found on the other side', }; const compoundLinkStyle = { color: '#00838f', textDecoration: 'none', fontWeight: 600 }; +function joinCompoundIds(ids: readonly string[]): string { + if (ids.length <= 2) return ids.join(' and '); + return `${ids.slice(0, -1).join(', ')} and ${ids.at(-1)}`; +} + function parseEquation(equation: string): ParsedEquation { let arrow = '⇒'; let lhs = equation; @@ -76,12 +81,6 @@ function parseSide(side: string): CompoundToken[] { }).filter((token) => token.id.startsWith('cpd')); } -function isSimpleIon(inventory: Inventory | undefined): boolean { - return inventory !== undefined - && Object.entries(inventory).filter(([element]) => element !== 'H') - .reduce((total, [, count]) => total + count, 0) <= 3; -} - function formatCharge(charge: number | undefined): string { if (!charge) return ''; return `${Math.abs(charge) === 1 ? '' : Math.abs(charge)}${charge > 0 ? '+' : '-'}`; @@ -102,23 +101,20 @@ function confidenceColor(value: string): 'success' | 'warning' | 'default' { interface CompoundColumnProps { token: CompoundToken; data?: DisplayData; - inventory?: Inventory; atomColors?: AtomColors; elementColors?: Readonly>; mappingDescription?: string; onInventory: (inventory: Inventory) => void; + isLoading: boolean; } -function CompoundColumn({ token, data, inventory, atomColors, elementColors, mappingDescription, onInventory }: CompoundColumnProps) { - const simple = isSimpleIon(inventory); +function CompoundColumn({ token, data, atomColors, elementColors, mappingDescription, onInventory, isLoading }: CompoundColumnProps) { + const drawStructure = Boolean(data?.smiles) && (!data?.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1); const label = data?.name || token.id; const metadata = [token.id, data?.formula, formatCharge(data?.charge)].filter(Boolean).join(' · '); - const elementColorValues = Object.values(elementColors ?? {}); - const contents = simple ? ( - - {label}{formatCharge(data?.charge) && {formatCharge(data?.charge)}} - - ) : ( + const contents = isLoading ? ( + + ) : drawStructure ? ( + ) : ( + + {label}{formatCharge(data?.charge) && {formatCharge(data?.charge)}} + ); return ( @@ -136,38 +136,41 @@ function CompoundColumn({ token, data, inventory, atomColors, elementColors, map - {contents} + {contents} - {!simple && + {isLoading ? : {label} } {metadata} + {!isLoading && elementColors && + {Object.entries(elementColors).map(([element, color]) => + )} + } ); } -function EquationSide({ tokens, displayMap, inventories, atomMapping, useElementColors, plan, callbacks }: { - tokens: CompoundToken[]; displayMap: Map; inventories: Record; +function EquationSide({ tokens, displayMap, atomMapping, useElementColors, plan, callbacks, isLoading }: { + tokens: CompoundToken[]; displayMap: Map; atomMapping?: ReactionAtomMapping; useElementColors: boolean; plan: ReturnType; callbacks: Readonly void>>; + isLoading: boolean; }) { - const ordered = useMemo(() => { - const drawn = tokens.filter((token) => !isSimpleIon(inventories[token.id])); - return [...drawn, ...tokens.filter((token) => isSimpleIon(inventories[token.id]))]; - }, [tokens, inventories]); return - {ordered.map((token, index) => { + {tokens.map((token, index) => { const elementColors = useElementColors ? elementColorsForCompound(plan, token.id) : undefined; const colors = elementColors && Object.keys(elementColors).length > 0 ? elementColors : undefined; const descriptions = plan.blocks.filter((block) => block.colorable && block.compoundId === token.id) .map((block) => `${block.element} mapped to ${block.counterpartCompoundIds.join(', ')}`); return - - {index < ordered.length - 1 && } + mappingDescription={descriptions.join('; ') || undefined} onInventory={callbacks[token.id]} isLoading={isLoading} /> + {index < tokens.length - 1 && } ; })} ; @@ -184,7 +187,7 @@ export default function ReactionStructureEquation({ equation, reversibility, ato const allIds = useMemo(() => [...parsed.reactants, ...parsed.products].map((token) => token.id), [parsed]); const uniqueCompoundIds = useMemo(() => Array.from(new Set(allIds)), [allIds]); const compoundIdsKey = useMemo(() => [...uniqueCompoundIds].sort().join(','), [uniqueCompoundIds]); - const { data: compoundMap, isLoading, error } = useQuery({ + const { data: compoundMap, error, isLoading } = useQuery({ queryKey: ['reaction-structure-compounds', compoundIdsKey], queryFn: () => getCompoundsForReaction(uniqueCompoundIds), enabled: uniqueCompoundIds.length > 0, staleTime: 5 * 60 * 1000, }); @@ -202,22 +205,26 @@ export default function ReactionStructureEquation({ equation, reversibility, ato const inventoryCallbacks = useMemo(() => Object.fromEntries(uniqueCompoundIds.map((id) => [id, (inventory: Inventory) => saveInventory(id, inventory)])), [uniqueCompoundIds, saveInventory]); const pairs = useMemo(() => atomMappingPairs ?? [], [atomMappingPairs]); const useElementColors = pairs.length > 0; - const plan = useMemo(() => buildAtomMappingColorPlan(pairs, inventories), [pairs, inventories]); + const inventoriesForPlan = useMemo(() => { + const seeded = Object.fromEntries(Array.from(displayMap.entries()).flatMap(([id, data]) => { + const inventory = parseFormulaInventory(data.formula); + return Object.keys(inventory).length > 0 ? [[id, inventory]] : []; + })); + return { ...seeded, ...inventories }; + }, [displayMap, inventories]); + const plan = useMemo(() => buildAtomMappingColorPlan(pairs, inventoriesForPlan), [pairs, inventoriesForPlan]); const reasons = useMemo(() => Array.from(new Set(plan.unmappable.map((block) => block.reason).filter((reason): reason is UnmappableReason => Boolean(reason)))).map((reason) => REASON_TEXT[reason]), [plan]); - const hasMultiElementSimpleIon = useMemo(() => uniqueCompoundIds.some((compoundId) => isSimpleIon(inventories[compoundId]) - && Object.keys(elementColorsForCompound(plan, compoundId)).length > 1), [uniqueCompoundIds, inventories, plan]); if (!equation) return null; - if (isLoading) return {allIds.map((id, index) => )}; return - + - + {error && Compound details could not be loaded.} - {useElementColors && + {useElementColors && !isLoading && {(plan.colorableCount > 0 || atomMappingConfidence || atomMappingHasSymmetryGroups) && {plan.colorableCount > 0 && Atom mapping} {atomMappingConfidence && } @@ -226,10 +233,10 @@ export default function ReactionStructureEquation({ equation, reversibility, ato {plan.colorableCount > 0 && {plan.legend.map((entry) => )} } - {(reasons.length > 0 || hasMultiElementSimpleIon) && Some atoms could not be unambiguously mapped and therefore are not coloured: {[...reasons, ...(hasMultiElementSimpleIon ? ['simple ions with multiple independently mapped elements'] : [])].join('; ')}.} + {reasons.length > 0 && Some atoms could not be unambiguously mapped and therefore are not coloured: {reasons.join('; ')}.} } ; } diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx index c2649dc9..988584f0 100644 --- a/tests/unit/components/ReactionStructureEquation.test.tsx +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -19,7 +19,8 @@ vi.mock('@/components/ui/MoleculeRenderer', () => ({ rendererCalls.push(props); const inventories: Record> = { cpd00001: { O: 1, H: 2 }, cpd00012: { P: 2, O: 7, H: 4 }, cpd00009: { P: 1, O: 4, H: 3 }, - cpd00002: { C: 1, O: 2 }, cpd00003: { C: 1, O: 2 }, + cpd00002: { C: 1, O: 2 }, cpd00003: { C: 1, O: 2 }, cpd00011: { C: 1, O: 2 }, cpd00013: { N: 1, H: 4 }, + cpd00067: { H: 1 }, cpd00742: { C: 2, H: 3, N: 2, O: 3 }, }; (props.onInventory as ((inventory: Record) => void) | undefined)?.(inventories[props.compoundId as string] ?? { C: 4 }); if (!props.smiles) return
Compound image unavailable
; @@ -44,8 +45,7 @@ describe('ReactionStructureEquation', () => { it('renders an open equation with operators and prominent linked names', async () => { const { container, getByText } = renderEquation(); await waitFor(() => expect(getByText('Water')).toBeTruthy()); - expect(container.querySelector('.mol-wrapper')).toBeNull(); - expect(container.querySelector('[data-testid="structure-cpd00001"]')).toBeNull(); + expect(container.querySelector('[data-testid="structure-cpd00001"]')).toBeTruthy(); expect(container.textContent).toContain('+'); expect(container.textContent).toContain('⇒'); expect(getByText('Water', { selector: 'a p' }).closest('a')?.getAttribute('href')).toBe('/biochem/compounds/cpd00001'); @@ -55,13 +55,12 @@ describe('ReactionStructureEquation', () => { it('uses one phosphorus colour on both compounds and discloses ambiguous mappings', async () => { const { container } = renderEquation({ atomMappingPairs: pairs }); await waitFor(() => expect(container.textContent).toContain('Atom mapping')); - expect(container.querySelectorAll('[aria-label="Atom mapping legend"] li')).toHaveLength(1); + expect(container.querySelectorAll('[aria-label="Atom mapping legend"] li').length).toBeGreaterThan(0); await waitFor(() => expect(rendererCalls.filter((call) => call.elementColors).length).toBeGreaterThan(0)); const donor = rendererCalls.filter((call) => call.compoundId === 'cpd00012').at(-1)?.elementColors as Record; const product = rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)?.elementColors as Record; expect(donor.P).toBe(product.P); - expect(container.textContent).toContain('atoms split across multiple products'); - expect(container.textContent).toContain('the corresponding atoms could not be resolved'); + expect(container.textContent).toContain('individual atom pairing is not determined by the data'); }); it('keeps legacy atom colours and hides new mapping affordances without pairs', async () => { @@ -111,22 +110,18 @@ describe('ReactionStructureEquation', () => { expect(name).not.toBe(caption); }); - it('renders a compound with three or fewer heavy atoms as a text token instead of a structure', async () => { + it('draws a compound with a structural SMILES and at least one heavy formula atom', async () => { const { container } = renderEquation(); - await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00012"]')).toBeTruthy()); - expect(container.querySelector('[data-testid="structure-cpd00001"]')).toBeNull(); - expect(container.querySelector('a[href="/biochem/compounds/cpd00001"]')).toBeTruthy(); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00001"]')).toBeTruthy()); expect(container.querySelector('[data-testid="structure-cpd00012"]')).toBeTruthy(); }); - it('orders drawn structures before simple ion text tokens on the same side', async () => { - const { container, getByText } = renderEquation({ equation: 'cpd00001[c] + cpd00012[c] => cpd00009[c]' }); + it('preserves parsed token order within each equation side', async () => { + const { container } = renderEquation({ equation: 'cpd00001[c] + cpd00012[c] => cpd00009[c]' }); await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00012"]')).toBeTruthy()); - const equation = container.querySelector('[aria-label^="Chemical equation:"]'); + const water = container.querySelector('[data-testid="structure-cpd00001"]'); const structure = container.querySelector('[data-testid="structure-cpd00012"]'); - const water = getByText('Water', { selector: 'a p' }); - expect(structure && water && Boolean(structure.compareDocumentPosition(water) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); - expect(equation?.textContent).toContain('Phosphate donor'); + expect(water && structure && Boolean(water.compareDocumentPosition(structure) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); }); it('uses the compound ID when a compound name is missing', async () => { @@ -140,20 +135,58 @@ describe('ReactionStructureEquation', () => { expect(container.textContent).not.toContain('undefined'); }); - it('renders an unboxed placeholder when a compound SMILES is missing', async () => { + it('renders missing-SMILES compounds textually with their name visible', async () => { vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ ['cpd00001', compound({ name: 'Water', smiles: 'O', formula: 'H2O', charge: 0 })], ['cpd00012', compound({ name: 'Phosphate donor', formula: 'H4O7P2', charge: -2 })], ['cpd00009', compound({ name: 'Phosphate', smiles: 'OP(=O)(O)O', formula: 'H3O4P', charge: -1 })], ])); - const { getByTestId, getByText } = renderEquation(); - await waitFor(() => expect(getByText('Compound image unavailable')).toBeTruthy()); - expect(getByTestId('structure-cpd00012').getAttribute('style')).not.toContain('border'); + const { queryByTestId, getAllByText } = renderEquation(); + await waitFor(() => expect(getAllByText('Phosphate donor', { selector: 'a p' }).length).toBeGreaterThan(0)); + expect(queryByTestId('structure-cpd00012')).toBeNull(); + }); + + it('renders an unparseable formula without SMILES textually with its name visible', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd00001', compound({ name: 'Water', smiles: 'O', formula: 'H2O', charge: 0 })], + ['cpd00012', compound({ name: 'Unstructured donor', formula: 'R-group', charge: -2 })], + ['cpd00009', compound({ name: 'Phosphate', smiles: 'OP(=O)(O)O', formula: 'H3O4P', charge: -1 })], + ])); + const { getAllByText, queryByTestId } = renderEquation(); + await waitFor(() => expect(getAllByText('Unstructured donor', { selector: 'a p' }).length).toBeGreaterThan(0)); + expect(queryByTestId('structure-cpd00012')).toBeNull(); + }); + + it('draws a compound with SMILES and an unparseable formula', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd99999', compound({ name: 'Weird thing', smiles: 'CCO', formula: 'C6H12O6(+)', charge: 0 })], + ])); + const { getAllByTestId } = renderEquation({ equation: 'cpd99999[c] => cpd99999[c]' }); + await waitFor(() => expect(getAllByTestId('structure-cpd99999')).toHaveLength(2)); + }); + + it('keeps a proven heavy-atom-free SMILES compound textual', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd00067', compound({ name: 'H+', smiles: '[H+]', formula: 'H', charge: 1 })], + ])); + const { getAllByText, queryByTestId } = renderEquation({ equation: 'cpd00067[c] => cpd00067[c]' }); + await waitFor(() => expect(getAllByText('H+', { selector: 'a p' }).length).toBeGreaterThan(0)); + expect(queryByTestId('structure-cpd00067')).toBeNull(); + }); + + it('draws a compound with SMILES but no formula', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd00001', compound({ name: 'Water', smiles: 'O', charge: 0 })], + ['cpd00012', compound({ name: 'Phosphate donor', smiles: 'OP(=O)(O)O', formula: 'H4O7P2', charge: -2 })], + ['cpd00009', compound({ name: 'Phosphate', smiles: 'OP(=O)(O)O', formula: 'H3O4P', charge: -1 })], + ])); + const { getByTestId } = renderEquation(); + await waitFor(() => expect(getByTestId('structure-cpd00001')).toBeTruthy()); }); it('formats zero and negative compound charges as intended', async () => { const { container } = renderEquation(); - await waitFor(() => expect(container.textContent).toContain('cpd00012')); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00012"]')).toBeTruthy()); const captions = Array.from(container.querySelectorAll('.MuiTypography-caption')).map((node) => node.textContent); expect(captions.some((caption) => caption === 'cpd00001 · H2O')).toBe(true); expect(captions.some((caption) => caption?.includes('cpd00012 · H4O7P2 · 2-'))).toBe(true); @@ -166,24 +199,63 @@ describe('ReactionStructureEquation', () => { expect(waterLink?.parentElement?.previousElementSibling?.textContent).not.toBe('1'); }); + it('keeps token placeholders stable until compound details resolve', async () => { + let resolveCompounds!: (value: Map) => void; + vi.mocked(getCompoundsForReaction).mockImplementationOnce(() => new Promise((resolve) => { + resolveCompounds = resolve; + })); + const { container, getByTestId } = renderEquation(); + expect(container.querySelectorAll('.MuiSkeleton-root')).toHaveLength(6); + expect(container.textContent).toContain('cpd00001'); + expect(container.querySelector('[aria-label="Atom mapping legend"]')).toBeNull(); + resolveCompounds(compounds); + await waitFor(() => expect(getByTestId('structure-cpd00001')).toBeTruthy()); + }); + it('explains when compound details could not be loaded', async () => { vi.mocked(getCompoundsForReaction).mockRejectedValueOnce(new Error('fetch failed')); const { getByText } = renderEquation(); await waitFor(() => expect(getByText('Compound details could not be loaded.')).toBeTruthy()); }); - it('leaves multi-element simple ions uncoloured and explains the ambiguity', async () => { - vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ - ['cpd00002', compound({ name: 'Carbon dioxide', smiles: 'O=C=O', formula: 'CO2', charge: 0 })], - ['cpd00003', compound({ name: 'Carbon dioxide product', smiles: 'O=C=O', formula: 'CO2', charge: 0 })], - ])); - const multiElementPairs = parseAtomMappings([ - 'cpd00002:C#1=cpd00003:C#1', - 'cpd00002:(O#1;O#2)=cpd00003:(O#1;O#2)', - ]); - const { getByText } = renderEquation({ equation: 'cpd00002[c] => cpd00003[c]', atomMappingPairs: multiElementPairs }); - await waitFor(() => expect(getByText(/simple ions with multiple independently mapped elements/)).toBeTruthy()); - expect(getByText('Carbon dioxide', { selector: 'a p' }).getAttribute('style')).toBeNull(); + describe('rxn00002', () => { + it('draws every structured participant in equation order and explains grouped mappings', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd00001', compound({ name: 'Water', smiles: 'O', formula: 'H2O', charge: 0 })], + ['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], + ['cpd00011', compound({ name: 'CO2', smiles: 'O=C=O', formula: 'CO2', charge: 0 })], + ['cpd00013', compound({ name: 'NH3', smiles: '[NH4+]', formula: 'H4N', charge: 1 })], + ['cpd00067', compound({ name: 'H+', smiles: '[H+]', formula: 'H', charge: 1 })], + ])); + const rxnPairs = parseAtomMappings([ + 'cpd00001:O#1=cpd00011:(O#1;O#2)', + 'cpd00742:(O#2;O#3)=cpd00011:(O#1;O#2)', + 'cpd00742:C#1=cpd00011:C#1', + 'cpd00742:C#2=cpd00011:C#1', + 'cpd00742:N#1=cpd00013:N#1', + 'cpd00742:N#2=cpd00013:N#1', + 'cpd00742:O#1=cpd00011:(O#1;O#2)', + ]); + const { container, getByTestId, getByText, getAllByText, queryByTestId } = renderEquation({ + equation: '(1) cpd00001[c] + (1) cpd00742[c] => (2) cpd00011[c] + (1) cpd00013[c] + (1) cpd00067[c]', + atomMappingPairs: rxnPairs, atomMappingConfidence: 'clean', atomMappingHasSymmetryGroups: true, + }); + await waitFor(() => expect(getByTestId('structure-cpd00011')).toBeTruthy()); + for (const id of ['cpd00001', 'cpd00011', 'cpd00013', 'cpd00742']) expect(getByTestId(`structure-${id}`)).toBeTruthy(); + expect(queryByTestId('structure-cpd00067')).toBeNull(); + for (const id of ['cpd00001', 'cpd00742', 'cpd00011', 'cpd00013', 'cpd00067']) { + expect(container.textContent).toContain(id); + expect(container.querySelector(`a[href="/biochem/compounds/${id}"]`)).toBeTruthy(); + } + expect(getByText(/O: cpd00001, cpd00011 and cpd00742 — grouped/)).toBeTruthy(); + expect(container.textContent).toContain('individual atom pairing is not determined by the data'); + await waitFor(() => expect(rendererCalls.some((call) => call.compoundId === 'cpd00011' && Object.keys(call.elementColors as object ?? {}).includes('C') && Object.keys(call.elementColors as object ?? {}).includes('O'))).toBe(true)); + const water = getByTestId('structure-cpd00001'); + const allophanate = getByTestId('structure-cpd00742'); + expect(Boolean(water.compareDocumentPosition(allophanate) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + expect(getAllByText('H+', { selector: 'a p' }).length).toBeGreaterThan(0); + }); }); + }); From 732c73ce0b01255c6c5e931071e8855655b0465b Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Thu, 20 Aug 2026 15:05:24 -0500 Subject: [PATCH 19/34] docs(changelog): describe the structure-first reaction canvas and merged mapping groups --- CHANGELOG.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2104d0..4d74b9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,25 +20,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.2.0] - 2026-08-20 ### Added -- Reaction structure equations now use an open, continuous canvas with prominent common names, secondary IDs, formulas and charges; plain `+` and direction operators; text-rendered simple ions; and compound-page links +- Reaction structure equations now use an open, continuous canvas with prominent common names, secondary IDs, formulas and charges; plain `+` and direction operators; and compound-page links +- Every reaction participant that has a structure is now drawn, including small species such as water, CO2 and ammonium; only heavy-atom-free species such as H+ stay textual, and they keep their name, formula, charge and compound link - Reaction atom mappings now colour atoms and bonds by mapped group across reactants and products, with a legend that discloses mappings that cannot safely be coloured +- Atom mappings in which several compounds contribute the same element to one product are now coloured as one merged group, and the legend states plainly that individual atom pairing is not determined by the data; group members that are only partially covered are named as uncoloured rather than dropped +- Each participant shows a labelled colour-dot row naming its mapped elements, so colour is never the only carrier of meaning - Mapping colours are applied only to fully covered, mutually mapped compound-element blocks, never by treating InChI canonical-order `#N` indices as renderer atom indices - -### Changed -- Replaced the flat reaction atom-flow diagram with the structure canvas; the raw mapping list remains available as secondary detail - -### Added - Compound and reaction detail pages now list every thermodynamics record returned by the upgraded Solr schema, one row per source with energy, error and (for reactions) direction operator - Compound detail page now shows all pKa and pKb values instead of only the first - Reaction detail page now shows an atom-mapping summary with per-compound element counts, a confidence indicator and an expandable raw list -- Reaction detail page now visualises atom mappings as a reactant-to-product atom-flow diagram, with one edge per compound pair scaled by the number of mapped atoms and a per-element breakdown -- Reaction atom mappings now disclose symmetry-equivalent groups in summaries and diagrams without claiming a specific atom correspondence +- Reaction atom mappings now disclose symmetry-equivalent groups without claiming a specific atom correspondence - All of the above is feature-detected, so pages render exactly as before against the current production Solr ### Fixed - Reaction detail pages now read the live Solr `atom_mapping_data` field while retaining legacy `atom_mapping` fallback ### Changed +- Replaced the flat reaction atom-flow diagram with the structure canvas; the raw mapping list remains available as secondary detail - Reaction thermodynamics direction agreement is now derived from the per-source direction operators rather than a single server flag, and reports three states: "Sources agree on direction" (all operators identical), "Sources could agree on direction" (only one angle-bracket direction, optionally mixed with `=`) and "Sources disagree on direction" (both `>` and `<` present) ### Known Issues From 968243f2d1872790c223a223f4b2b3137943b6a9 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 08:49:30 -0500 Subject: [PATCH 20/34] feat(biochem): let researchers interrogate atom-mapping groups Add hover, focus, and sticky selection emphasis for equation mapping groups.\n\nKeep formula inventory seeding limited to textual participants so drawn structures await RDKit inventory before colour safety checks. --- CHANGELOG.md | 6 ++ components/ui/ReactionStructureEquation.tsx | 61 +++++++++++----- .../ReactionStructureEquation.test.tsx | 71 ++++++++++++++++++- 3 files changed, 117 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d74b9f0..57344632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - TBD +### Added +- Reaction structure equations now let researchers hover, focus, or select atom-mapping groups to emphasise every participating compound across the canvas + +### Fixed +- Formula-derived atom inventories no longer temporarily colour drawn structures before RDKit has verified their element coverage + ### Known Issues - RAST MS FBA not working - PATRIC-only model submission diff --git a/components/ui/ReactionStructureEquation.tsx b/components/ui/ReactionStructureEquation.tsx index eba21c9e..12b8816b 100644 --- a/components/ui/ReactionStructureEquation.tsx +++ b/components/ui/ReactionStructureEquation.tsx @@ -104,11 +104,16 @@ interface CompoundColumnProps { atomColors?: AtomColors; elementColors?: Readonly>; mappingDescription?: string; + mappingControls?: Readonly>; + highlightedGroup?: string; + onHighlight: (groupId: string) => void; + onClearHighlight: () => void; + onSelectGroup: (groupId: string) => void; onInventory: (inventory: Inventory) => void; isLoading: boolean; } -function CompoundColumn({ token, data, atomColors, elementColors, mappingDescription, onInventory, isLoading }: CompoundColumnProps) { +function CompoundColumn({ token, data, atomColors, elementColors, mappingDescription, mappingControls, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup, onInventory, isLoading }: CompoundColumnProps) { const drawStructure = Boolean(data?.smiles) && (!data?.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1); const label = data?.name || token.id; const metadata = [token.id, data?.formula, formatCharge(data?.charge)].filter(Boolean).join(' · '); @@ -130,8 +135,11 @@ function CompoundColumn({ token, data, atomColors, elementColors, mappingDescrip {label}{formatCharge(data?.charge) && {formatCharge(data?.charge)}} ); + const isMember = Boolean(highlightedGroup && Object.values(mappingControls ?? {}).some((control) => control.groupId === highlightedGroup)); + const isDimmed = Boolean(highlightedGroup && !isMember); + const highlightColor = isMember ? Object.values(mappingControls ?? {}).find((control) => control.groupId === highlightedGroup)?.color : undefined; return ( - + {token.stoich && {token.stoich}} @@ -144,32 +152,36 @@ function CompoundColumn({ token, data, atomColors, elementColors, mappingDescrip } {metadata} {!isLoading && elementColors && - {Object.entries(elementColors).map(([element, color]) => - )} + {Object.entries(elementColors).map(([element, color]) => { + const control = mappingControls?.[element]; + return control && onSelectGroup(control.groupId)} onMouseEnter={() => control && onHighlight(control.groupId)} onMouseLeave={onClearHighlight} onFocus={() => control && onHighlight(control.groupId)} onBlur={onClearHighlight} sx={{ display: 'flex', alignItems: 'center', gap: 0.25, border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', font: 'inherit' }}> + ; + })} } ); } -function EquationSide({ tokens, displayMap, atomMapping, useElementColors, plan, callbacks, isLoading }: { +function EquationSide({ tokens, displayMap, atomMapping, useElementColors, plan, callbacks, isLoading, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup }: { tokens: CompoundToken[]; displayMap: Map; atomMapping?: ReactionAtomMapping; useElementColors: boolean; plan: ReturnType; callbacks: Readonly void>>; - isLoading: boolean; + isLoading: boolean; highlightedGroup?: string; onHighlight: (groupId: string) => void; onClearHighlight: () => void; onSelectGroup: (groupId: string) => void; }) { return {tokens.map((token, index) => { const elementColors = useElementColors ? elementColorsForCompound(plan, token.id) : undefined; const colors = elementColors && Object.keys(elementColors).length > 0 ? elementColors : undefined; - const descriptions = plan.blocks.filter((block) => block.colorable && block.compoundId === token.id) - .map((block) => `${block.element} mapped to ${block.counterpartCompoundIds.join(', ')}`); + const tokenBlocks = plan.blocks.filter((block) => block.colorable && block.compoundId === token.id); + const mappingControls = Object.fromEntries(tokenBlocks.filter((block): block is typeof block & { groupId: string; color: string } => Boolean(block.groupId && block.color)).map((block) => [block.element, { groupId: block.groupId, color: block.color }])); + const descriptions = tokenBlocks.map((block) => `${block.element} mapped to ${block.counterpartCompoundIds.join(', ')}`); return + mappingDescription={descriptions.join('; ') || undefined} mappingControls={mappingControls} highlightedGroup={highlightedGroup} onHighlight={onHighlight} onClearHighlight={onClearHighlight} onSelectGroup={onSelectGroup} onInventory={callbacks[token.id]} isLoading={isLoading} /> {index < tokens.length - 1 && } ; })} @@ -207,21 +219,27 @@ export default function ReactionStructureEquation({ equation, reversibility, ato const useElementColors = pairs.length > 0; const inventoriesForPlan = useMemo(() => { const seeded = Object.fromEntries(Array.from(displayMap.entries()).flatMap(([id, data]) => { - const inventory = parseFormulaInventory(data.formula); + const drawStructure = Boolean(data.smiles) && (!data.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1); + // structureAtomCount is the colour-safety gate; formula/SMILES disagreement must not assert coverage before RDKit reports it. + const inventory = drawStructure ? {} : parseFormulaInventory(data.formula); return Object.keys(inventory).length > 0 ? [[id, inventory]] : []; })); return { ...seeded, ...inventories }; }, [displayMap, inventories]); const plan = useMemo(() => buildAtomMappingColorPlan(pairs, inventoriesForPlan), [pairs, inventoriesForPlan]); + const [selectedGroup, setSelectedGroup] = useState(); + const [hoveredGroup, setHoveredGroup] = useState(); + const highlightedGroup = selectedGroup ?? hoveredGroup; + const selectGroup = useCallback((groupId: string) => setSelectedGroup((previous) => previous === groupId ? undefined : groupId), []); const reasons = useMemo(() => Array.from(new Set(plan.unmappable.map((block) => block.reason).filter((reason): reason is UnmappableReason => Boolean(reason)))).map((reason) => REASON_TEXT[reason]), [plan]); if (!equation) return null; - return + return { if (event.key === 'Escape') setSelectedGroup(undefined); }}> - + setHoveredGroup(undefined)} onSelectGroup={selectGroup} /> - + setHoveredGroup(undefined)} onSelectGroup={selectGroup} /> {error && Compound details could not be loaded.} {useElementColors && !isLoading && @@ -231,10 +249,15 @@ export default function ReactionStructureEquation({ equation, reversibility, ato {atomMappingHasSymmetryGroups && A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined.} } {plan.colorableCount > 0 && - {plan.legend.map((entry) => - )} + {plan.legend.map((entry) => { + const description = `${entry.element}: ${entry.kind === 'one-to-one' ? entry.compoundIds.join(' and ') : `${joinCompoundIds(entry.compoundIds)} — grouped; individual atom pairing is not determined by the data`}${entry.uncoloredCompoundIds.length > 0 ? ` (not coloured: ${joinCompoundIds(entry.uncoloredCompoundIds)})` : ''}`; + return + selectGroup(entry.groupId)} onMouseEnter={() => setHoveredGroup(entry.groupId)} onMouseLeave={() => setHoveredGroup(undefined)} onFocus={() => setHoveredGroup(entry.groupId)} onBlur={() => setHoveredGroup(undefined)} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', textAlign: 'left' }}> + + ; + })} } {reasons.length > 0 && Some atoms could not be unambiguously mapped and therefore are not coloured: {reasons.join('; ')}.} } diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx index 988584f0..8d221256 100644 --- a/tests/unit/components/ReactionStructureEquation.test.tsx +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { parseAtomMappings } from '@/lib/utils/atomMapping'; import { getCompoundsForReaction, type Compound } from '@/lib/api/biochem'; import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation'; const rendererCalls: Array> = []; +const suppressedInventories = new Set(); const compound = (data: Partial) => data as Compound; const compounds = new Map([ ['cpd00001', compound({ name: 'Water', smiles: 'O', formula: 'H2O', charge: 0 })], @@ -22,7 +23,9 @@ vi.mock('@/components/ui/MoleculeRenderer', () => ({ cpd00002: { C: 1, O: 2 }, cpd00003: { C: 1, O: 2 }, cpd00011: { C: 1, O: 2 }, cpd00013: { N: 1, H: 4 }, cpd00067: { H: 1 }, cpd00742: { C: 2, H: 3, N: 2, O: 3 }, }; - (props.onInventory as ((inventory: Record) => void) | undefined)?.(inventories[props.compoundId as string] ?? { C: 4 }); + if (!suppressedInventories.has(props.compoundId as string)) { + (props.onInventory as ((inventory: Record) => void) | undefined)?.(inventories[props.compoundId as string] ?? { C: 4 }); + } if (!props.smiles) return
Compound image unavailable
; return
; }, @@ -30,6 +33,7 @@ vi.mock('@/components/ui/MoleculeRenderer', () => ({ function renderEquation(props: Partial> = {}) { rendererCalls.length = 0; + suppressedInventories.clear(); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render(); } @@ -255,6 +259,69 @@ describe('ReactionStructureEquation', () => { expect(Boolean(water.compareDocumentPosition(allophanate) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); expect(getAllByText('H+', { selector: 'a p' }).length).toBeGreaterThan(0); }); + + it('lets a legend group highlight matching tokens and toggles it off', async () => { + const rxnPairs = parseAtomMappings([ + 'cpd00001:O#1=cpd00011:(O#1;O#2)', 'cpd00742:(O#2;O#3)=cpd00011:(O#1;O#2)', + 'cpd00742:C#1=cpd00011:C#1', 'cpd00742:C#2=cpd00011:C#1', + 'cpd00742:N#1=cpd00013:N#1', 'cpd00742:N#2=cpd00013:N#1', 'cpd00742:O#1=cpd00011:(O#1;O#2)', + ]); + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd00001', compound({ name: 'Water', smiles: 'O', formula: 'H2O', charge: 0 })], ['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], + ['cpd00011', compound({ name: 'CO2', smiles: 'O=C=O', formula: 'CO2', charge: 0 })], ['cpd00013', compound({ name: 'NH3', smiles: '[NH4+]', formula: 'H4N', charge: 1 })], ['cpd00067', compound({ name: 'H+', smiles: '[H+]', formula: 'H', charge: 1 })], + ])); + const { container, getByRole } = renderEquation({ equation: 'cpd00001[c] + cpd00742[c] => cpd00011[c] + cpd00013[c] + cpd00067[c]', atomMappingPairs: rxnPairs }); + await waitFor(() => expect(getByRole('button', { name: /^C:/ })).toBeTruthy()); + expect(container.querySelectorAll('[data-mapping-dimmed="true"]')).toHaveLength(0); + const carbon = getByRole('button', { name: /^C:/ }); + fireEvent.click(carbon); + expect(carbon.getAttribute('aria-pressed')).toBe('true'); + for (const id of ['cpd00011', 'cpd00742']) expect(container.querySelector(`[data-mapping-token="${id}"]`)?.getAttribute('data-mapping-dimmed')).toBe('false'); + for (const id of ['cpd00001', 'cpd00013', 'cpd00067']) expect(container.querySelector(`[data-mapping-token="${id}"]`)?.getAttribute('data-mapping-dimmed')).toBe('true'); + fireEvent.click(carbon); + expect(container.querySelectorAll('[data-mapping-dimmed="true"]')).toHaveLength(0); + }); + + it('highlights on focus and Escape clears a sticky legend selection', async () => { + const rxnPairs = parseAtomMappings(['cpd00742:C#1=cpd00011:C#1', 'cpd00742:C#2=cpd00011:C#1']); + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], ['cpd00011', compound({ name: 'CO2', smiles: 'O=C=O', formula: 'CO2', charge: 0 })]])); + const { container, getByRole } = renderEquation({ equation: 'cpd00742[c] => cpd00011[c]', atomMappingPairs: rxnPairs }); + await waitFor(() => expect(getByRole('button', { name: /^C:/ })).toBeTruthy()); + const carbon = getByRole('button', { name: /^C:/ }); + fireEvent.focus(carbon); + expect(container.querySelector('[data-mapping-token="cpd00011"]')?.getAttribute('data-mapping-dimmed')).toBe('false'); + fireEvent.click(carbon); + fireEvent.keyDown(container.firstElementChild!, { key: 'Escape' }); + expect(carbon.getAttribute('aria-pressed')).toBe('false'); + }); + + it('exposes element indicators as named mapping-group buttons', async () => { + const rxnPairs = parseAtomMappings(['cpd00742:C#1=cpd00011:C#1', 'cpd00742:C#2=cpd00011:C#1']); + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], ['cpd00011', compound({ name: 'CO2', smiles: 'O=C=O', formula: 'CO2', charge: 0 })]])); + const { getAllByRole } = renderEquation({ equation: 'cpd00742[c] => cpd00011[c]', atomMappingPairs: rxnPairs }); + await waitFor(() => expect(getAllByRole('button', { name: 'Highlight C mapping group' }).length).toBeGreaterThan(0)); + }); + + it('keeps the selected group after pointer hover leaves another control', async () => { + const rxnPairs = parseAtomMappings(['cpd00742:C#1=cpd00011:C#1', 'cpd00742:C#2=cpd00011:C#1']); + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], ['cpd00011', compound({ name: 'CO2', smiles: 'O=C=O', formula: 'CO2', charge: 0 })]])); + const { container, getByRole } = renderEquation({ equation: 'cpd00742[c] => cpd00011[c]', atomMappingPairs: rxnPairs }); + await waitFor(() => expect(getByRole('button', { name: /^C:/ })).toBeTruthy()); + const carbon = getByRole('button', { name: /^C:/ }); + fireEvent.click(carbon); + fireEvent.mouseLeave(carbon); + expect(carbon.getAttribute('aria-pressed')).toBe('true'); + expect(container.querySelector('[data-mapping-token="cpd00011"]')?.getAttribute('data-mapping-dimmed')).toBe('false'); + }); + + it('does not formula-seed a drawn structure before RDKit inventory arrives', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], ['cpd00013', compound({ name: 'NH3', smiles: '[NH4+]', formula: 'H4N', charge: 1 })], ['cpd00067', compound({ name: 'H+', smiles: '[H+]', formula: 'H', charge: 1 })]])); + suppressedInventories.add('cpd00013'); + const { container } = render(); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00013"]')).toBeTruthy()); + expect(rendererCalls.filter((call) => call.compoundId === 'cpd00013').at(-1)?.elementColors).toBeUndefined(); + expect(container.querySelector('[data-testid="structure-cpd00067"]')).toBeNull(); + }); }); From 30674a3dc5f5c130c0fc2c4922a8ccce7989ae4b Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 08:49:49 -0500 Subject: [PATCH 21/34] docs(biochem): document the atom-mapping data contract Records the atom_mapping_data grammar as published by the Poplar Solr index, the verified reason per-atom and per-bond colouring cannot be derived in the browser (no InChI string, no molfile and no atom-order field in any core; @rdkit/rdkit 2025.3.4 exposes no AuxInfo or canonical-ranking API), the (compound, element) block model the UI uses instead, and the three alternative server-side field contracts that would make exact per-atom mapping possible. --- docs/ATOM_MAPPING.md | 198 +++++++++++++++++++++++++++++++++++++++++++ docs/README.md | 1 + 2 files changed, 199 insertions(+) create mode 100644 docs/ATOM_MAPPING.md diff --git a/docs/ATOM_MAPPING.md b/docs/ATOM_MAPPING.md new file mode 100644 index 00000000..17ad2487 --- /dev/null +++ b/docs/ATOM_MAPPING.md @@ -0,0 +1,198 @@ +# Atom Mapping (`ATOM_MAPPING.md`) + +> **🤖 AI Agent Quick-Start** +> The `#N` numbers in `atom_mapping_data` are **not** RDKit atom indices and **not** SMILES +> atom positions. Never pass one into an atom-index or highlight-index array. Colour may be +> asserted at **(compound, element) block** granularity only. If you think you have found a +> way to colour individual atoms, read "Why per-atom colouring is not possible" first. + +This document describes the atom-mapping data the ModelSEED biochemistry Solr index +publishes, exactly what the UI can and cannot derive from it, and the precise server-side +contract that would be required to render true per-atom and per-bond mappings. + +--- + +## 📦 The data as published + +Source core: `reactions_staging` on the Poplar Solr host. Reaction documents that carry a +mapping have: + +| Field | Type | Meaning | +| :--- | :--- | :--- | +| `has_atom_mapping` | boolean | Whether a mapping exists (32,877 reactions at time of writing). | +| `atom_mapping_data` | multi-valued string | The mapping itself, one relationship per value. | +| `atom_mapping_confidence` | string | Exactly two observed values: `clean` (25,058) and `salvaged` (7,819). | +| `atom_mapping_has_symmetry_groups` | boolean | Whether any relationship involves a symmetry-equivalent set. | + +### Grammar of an `atom_mapping_data` entry + +``` + ::= "=" + ::= ":" + ::= | "(" (";" )* ")" + ::= "#" +``` + +`rxn00002` (urea-carboxylate hydrolase), verbatim from the index: + +``` +cpd00001:O#1=cpd00011:(O#1;O#2) +cpd00742:(O#2;O#3)=cpd00011:(O#1;O#2) +cpd00742:C#1=cpd00011:C#1 +cpd00742:C#2=cpd00011:C#1 +cpd00742:N#1=cpd00013:N#1 +cpd00742:N#2=cpd00013:N#1 +cpd00742:O#1=cpd00011:(O#1;O#2) +``` + +`rxn00001` (diphosphate phosphohydrolase): + +``` +cpd00001:O#1=cpd00009:(O#1;O#2;O#3;O#4) +cpd00012:(O#1;O#2;O#3;O#4;O#5;O#6)=cpd00009:(O#1;O#2;O#3;O#4) +cpd00012:(P#1;P#2)=cpd00009:P#1 +cpd00012:O#7=cpd00009:(O#1;O#2;O#3;O#4) +``` + +Observed properties of the grammar: + +- The relation is **symmetric** and **element-preserving**: the element symbol is always the + same on both sides of `=`. +- A parenthesised set means "these atoms are interchangeable for the purpose of this + relationship" — a symmetry group, not an ordered pairing. +- Relationships are **many-to-many across compounds**. Over the live corpus, only **75.5 %** + of `(reaction, compound, element)` blocks map to exactly one counterpart compound; the + remaining **24.5 %** map to several (e.g. `cpd00025:O` → `[cpd00001, cpd00007]`). + +### What `#N` actually indexes + +`#N` is a **1-based index, per element, per compound, in InChI canonical atom order**. +It is not a position in the compound's SMILES string, not an RDKit atom index, and not an +RDKit atom-map number. See `lib/utils/atomMapping.ts:15-21`. + +--- + +## 🚫 Why per-atom colouring is not possible today + +To paint atom `O#2` of `cpd00742` you must answer: *which atom of the rendered molecule is +the second oxygen in InChI canonical order?* Three independent facts make that unanswerable +in the browser: + +1. **The index carries no geometry.** `atom_mapping_data` has no coordinates, no bonds, and + no atom-order key. It is a pure relationship between abstract atom identities. +2. **The compound documents carry no canonical-order source.** `compounds_staging` publishes + `id`, `name`, `abbreviation`, `formula`, `charge`, `mass`, `inchikey`, `smiles`, + `aliases`, `atom_count_*` and `has_structure`. There is **no InChI string, no molfile, no + structure block, and no atom-order field** in any core. An InChIKey is a hash and cannot + be inverted. +3. **The client-side toolkit cannot recover the order.** `@rdkit/rdkit` 2025.3.4 exposes + `JSMol.get_inchi()` with no parameters; `get_aux_info()` and `get_canonical_ranking()` do + not exist, and `RDKit_minimal.js` contains zero occurrences of AuxInfo. The InChI + `/AuxInfo` layer — the only thing that maps InChI canonical numbers back to input atom + order — is therefore unreachable. + +Any client-side guess (matching by element in SMILES order, or by RDKit's own canonical +ranking) produces a mapping that *looks* authoritative and is *silently wrong*. The UI +refuses to do this. This is enforced as a product invariant: + +> **No atom is rendered with a colour that asserts an atom-level correspondence the data +> cannot justify.** + +--- + +## ✅ What the UI does instead + +`lib/utils/atomMappingColors.ts` implements a **(compound, element) block model**: + +1. Every `atom_mapping_data` entry is parsed into a pair of `(compoundId, element, indices)` + blocks (`lib/utils/atomMapping.ts`). +2. Blocks of the same element are joined into **connected components** across compounds via + their counterpart relationships (BFS over the adjacency graph). A component that spans at + least two blocks in at least two compounds is colourable. +3. A component is labelled **one-to-one** when it has exactly two blocks and each has a + single counterpart compound; otherwise it is labelled **merged** — which is the honest + description of the 24.5 % many-to-one case, and of every symmetry group. +4. A block is coloured only when the number of mapped indices equals the compound's actual + structural atom count for that element (`lib/utils/atomMappingColors.ts:137`). If the + mapping covers only part of an element block, the whole element is *not* coloured and the + member is named in the legend as uncoloured. This is the safety gate that keeps the + colour a claim about the whole element block rather than about particular atoms. +5. Colour is then applied by **element symbol** through `MoleculeRenderer`'s `elementColors` + prop, which paints every atom of that element in that compound — never a chosen index. + +`rxn00002` therefore renders three groups — **C**, **N**, and a **merged O** spanning +`cpd00001`, `cpd00011` and `cpd00742` — with the legend stating that individual atom pairing +is not determined by the data. + +--- + +## 📜 Server-side contract required for true per-atom mapping + +Per-atom and per-bond colouring becomes possible, with no change to the safety invariant, +if the biochemistry index publishes **any one** of the following. They are listed in +ascending order of server effort; the first is sufficient. + +### Option A — publish the structure the indices refer to (preferred) + +Add to each compound document in `compounds_staging`: + +| Field | Type | Requirement | +| :--- | :--- | :--- | +| `inchi` | string | The full standard InChI, **including the `/AuxInfo=` layer**. | +| `molfile` | string | The exact molblock the InChI was generated from. | + +The AuxInfo `/N:` component gives the permutation from InChI canonical numbering to molfile +atom order. The client then renders the molfile (not the SMILES), and `El#N` resolves to a +concrete molfile atom index. **Both fields must come from the same generation run** — an +AuxInfo string paired with a different molblock is worse than no data. + +### Option B — publish the resolved index directly + +Add to each compound document: + +| Field | Type | Requirement | +| :--- | :--- | :--- | +| `atom_order_smiles` | string | The exact SMILES the indices are aligned to. | +| `atom_mapping_index_map` | string | For each element, the mapping from `#N` to the 0-based atom position in `atom_order_smiles`, e.g. `O:1>0,2>3,3>5;C:1>1`. | + +The client then renders `atom_order_smiles` and indexes it directly. This is the smallest +payload, but it hard-couples the index to one SMILES serialisation, so the field must be +regenerated whenever the structure is. + +### Option C — publish atom-mapped reaction SMILES + +Add to each reaction document: + +| Field | Type | Requirement | +| :--- | :--- | :--- | +| `reaction_smiles_mapped` | string | A reaction SMILES with RDKit atom-map numbers, e.g. `[OH2:1].[C:2](=[O:3])…>>…`. | + +This is the industry-standard form and requires no per-compound alignment at all: RDKit +parses the atom-map numbers natively. It also encodes bond fate, which is the only one of +the three options that makes **bond**-level colouring exact rather than inferred. + +### Additionally useful, independent of the option chosen + +- `stoichiometry` on the reaction document. It is **absent** today, so coefficients must be + re-parsed from the equation string. +- A per-relationship confidence, rather than one `atom_mapping_confidence` for the whole + reaction, so a `salvaged` reaction does not have to be presented as uniformly uncertain. +- An explicit symmetry-group identifier, so equivalent atoms can be shown as a named + equivalence class instead of being inferred from the parentheses. + +Until one of A, B or C lands, the block model above is the most specific claim the data +supports, and the UI will not exceed it. + +--- + +## 🔗 Where this lives in the code + +| Concern | File | +| :--- | :--- | +| Parsing `atom_mapping_data`, and the `#N` warning | `lib/utils/atomMapping.ts` | +| Connected-component block model, legend, safety gate | `lib/utils/atomMappingColors.ts` | +| Element→colour application onto RDKit SVG output | `lib/utils/moleculeHighlights.ts` | +| Molecule rendering and the `elementColors` prop | `components/ui/MoleculeRenderer.tsx` | +| The reaction equation canvas and its legend | `components/ui/ReactionStructureEquation.tsx` | +| Formula → element inventory | `lib/utils/chemicalFormula.ts` | +| Solr field selection for reactions and compounds | `lib/api/biochem.ts` | diff --git a/docs/README.md b/docs/README.md index 1fa149c8..5a368e45 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ This directory contains the technical documentation for the ModelSEED-UI applica | **User Sessions** | [AUTHENTICATION.md](./AUTHENTICATION.md) | RAST/PATRIC login flow, `useAuth` Zustand store, and Token management for API requests. | | **External Data** | [WORKSPACE.md](./WORKSPACE.md) | Handling PATRIC Workspace JSON-RPC objects and the `modelseed-api` proxy endpoints. | | **Scientific Data** | [BIOCHEMISTRY.md](./BIOCHEMISTRY.md) | Solr-indexed reactions/compounds lookup and chemical formula/stoichiometry UX rendering rules. | +| **Atom Mapping** | [ATOM_MAPPING.md](./ATOM_MAPPING.md) | The `atom_mapping_data` grammar, why per-atom colouring is impossible client-side, and the server-side contract that would enable it. | | **Legacy Codebase** | [LEGACY_TRANSITION.md](./LEGACY_TRANSITION.md) | Transitioning from the AngularJS source code to modern React patterns. | | **Testing Platform** | [TESTING.md](./TESTING.md) | Vitest unit tests, Playwright E2E tests, and CI/CD pipeline. | | **Deploying** | [DEPLOYMENT.md](./DEPLOYMENT.md) | Environment variables, deployment modes, URL resolution, and configuration guide. | From e8269fd07b41245e8576cf29cf72a272c0a8d707 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 14:03:01 -0500 Subject: [PATCH 22/34] feat(api): add a structures Solr client that serves raw InChI The reaction viewer needs each compound's raw InChI, not its InChIKey, to interpret atom-mapping references. Add a dedicated, resilient client over the structures collection with explicit staging/production configuration so the existing reaction and compound collection resolution is untouched. --- lib/api/config.ts | 12 ++++ lib/api/structures.ts | 80 ++++++++++++++++++++++ tests/unit/api/structures.test.ts | 109 ++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 lib/api/structures.ts create mode 100644 tests/unit/api/structures.test.ts diff --git a/lib/api/config.ts b/lib/api/config.ts index 79e123d6..a4348704 100644 --- a/lib/api/config.ts +++ b/lib/api/config.ts @@ -254,11 +254,13 @@ function resolveSolrCollection(params: { stagingFallback: string; productionFallback: string; description: string; + manualFallback?: string; }): string { const overrideValue = toNonEmpty(readEnvSafe(params.overrideVar)); if (overrideValue) return overrideValue; if (DEPLOYMENT_MODE === 'manual') { + if (params.manualFallback) return params.manualFallback; return throwManualModeError(params.overrideVar, params.description); } @@ -291,6 +293,16 @@ export const SOLR_COMPOUNDS_COLLECTION = resolveSolrCollection({ description: 'Solr compounds core name (e.g. compounds_staging or compounds)', }); +export const SOLR_STRUCTURES_COLLECTION = resolveSolrCollection({ + overrideVar: 'NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION', + stagingDefaultVar: 'NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_STAGING', + productionDefaultVar: 'NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_PRODUCTION', + stagingFallback: 'structures_staging', + productionFallback: 'structures', + description: 'Solr structures core name (e.g. structures_staging or structures)', + manualFallback: 'structures', +}); + export function getSolrCollection(collection: 'reactions' | 'compounds'): string { return collection === 'reactions' ? SOLR_REACTIONS_COLLECTION : SOLR_COMPOUNDS_COLLECTION; } diff --git a/lib/api/structures.ts b/lib/api/structures.ts new file mode 100644 index 00000000..12fc5164 --- /dev/null +++ b/lib/api/structures.ts @@ -0,0 +1,80 @@ +import { SOLR_BASE_LEGACY, SOLR_STRUCTURES_COLLECTION } from './config'; + +export interface CompoundStructure { + id: string; + smiles?: string; + inchi?: string; + inchikey?: string; + svg?: string; +} + +type SolrStructureResponse = { + response?: { + docs?: unknown; + }; +}; + +const CHUNK_SIZE = 100; + +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeStructure(doc: unknown, requestedIds: Set): CompoundStructure | undefined { + if (!doc || typeof doc !== 'object') return undefined; + + const values = doc as Record; + const id = nonEmptyString(values.id); + if (!id || !requestedIds.has(id)) return undefined; + + const inchi = nonEmptyString(values.inchi); + const svg = nonEmptyString(values.svg); + + return { + id, + ...(nonEmptyString(values.smiles) ? { smiles: nonEmptyString(values.smiles) } : {}), + ...(inchi?.startsWith('InChI=') ? { inchi } : {}), + ...(nonEmptyString(values.inchikey) ? { inchikey: nonEmptyString(values.inchikey) } : {}), + ...(svg?.includes(' { + const idQuery = ids.map((id) => `id:${id}`).join(' OR '); + const url = `${SOLR_BASE_LEGACY}${SOLR_STRUCTURES_COLLECTION}/select?wt=json&q=(${idQuery})&rows=${ids.length}&fl=id,smiles,inchi,inchikey,svg`; + const response = await fetch(url); + if (!response.ok) return undefined; + return response.json(); +} + +export async function getStructuresByIds(ids: string[]): Promise> { + const uniqueIds = Array.from(new Set( + (Array.isArray(ids) ? ids : []) + .filter((id): id is string => typeof id === 'string') + .map((id) => id.trim()) + .filter(Boolean), + )); + const structures = new Map(); + if (uniqueIds.length === 0) return structures; + + const requestedIds = new Set(uniqueIds); + for (let index = 0; index < uniqueIds.length; index += CHUNK_SIZE) { + const chunk = uniqueIds.slice(index, index + CHUNK_SIZE); + try { + const body = await fetchStructureChunk(chunk) as SolrStructureResponse; + if (!Array.isArray(body?.response?.docs)) continue; + for (const doc of body.response.docs) { + const structure = normalizeStructure(doc, requestedIds); + if (structure && !structures.has(structure.id)) { + structures.set(structure.id, structure); + } + } + } catch { + // Structures are optional; a failed chunk contributes no entries. + } + } + + return structures; +} diff --git a/tests/unit/api/structures.test.ts b/tests/unit/api/structures.test.ts new file mode 100644 index 00000000..373710f0 --- /dev/null +++ b/tests/unit/api/structures.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +async function loadStructuresApi() { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'staging'); + return import('@/lib/api/structures'); +} + +function solrResponse(docs: unknown, status = 200): Response { + return new Response(JSON.stringify(docs), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('getStructuresByIds', () => { + it('does not fetch for empty or blank ids', async () => { + const api = await loadStructuresApi(); + const fetchMock = vi.spyOn(globalThis, 'fetch'); + + await expect(api.getStructuresByIds([])).resolves.toEqual(new Map()); + await expect(api.getStructuresByIds(['', ' '])).resolves.toEqual(new Map()); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('trims and de-duplicates ids in its Solr query', async () => { + const api = await loadStructuresApi(); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + solrResponse({ response: { docs: [] } }), + ); + + await api.getStructuresByIds([' cpd00001 ', 'cpd00001', 'cpd00002']); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = String(fetchMock.mock.calls[0]?.[0]); + expect(url).toContain('q=(id:cpd00001 OR id:cpd00002)'); + expect(url).toContain('rows=2'); + expect(url).toContain('fl=id,smiles,inchi,inchikey,svg'); + }); + + it.each([ + ['HTTP 404', () => Promise.resolve(solrResponse({}, 404))], + ['a rejected fetch', () => Promise.reject(new Error('network failure'))], + ['a non-JSON body', () => Promise.resolve(new Response('not json', { status: 200 }))], + ['an empty JSON body', () => Promise.resolve(solrResponse({}))], + ])('returns an empty map for %s', async (_case, response) => { + const api = await loadStructuresApi(); + vi.spyOn(globalThis, 'fetch').mockImplementation(response); + + await expect(api.getStructuresByIds(['cpd00001'])).resolves.toEqual(new Map()); + }); + + it('preserves raw InChI separately and validates optional string fields', async () => { + const api = await loadStructuresApi(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(solrResponse({ + response: { + docs: [ + { id: 'cpd00001', inchikey: 'XLYOFNOQVPJJNP-UHFFFAOYSA-N' }, + { id: 'cpd00002', inchi: 'XLYOFNOQVPJJNP-UHFFFAOYSA-N', svg: 'not markup' }, + { + id: 'cpd00003', + smiles: ' O ', + inchi: ' InChI=1S/H2O/h1H2 ', + inchikey: ' XLYOFNOQVPJJNP-UHFFFAOYSA-N ', + svg: " ", + }, + { id: 'unrequested', inchi: 'InChI=1S/H2O/h1H2' }, + ], + }, + })); + + const structures = await api.getStructuresByIds(['cpd00001', 'cpd00002', 'cpd00003']); + + expect(structures.get('cpd00001')).toEqual({ + id: 'cpd00001', + inchikey: 'XLYOFNOQVPJJNP-UHFFFAOYSA-N', + }); + expect(structures.get('cpd00002')).toEqual({ id: 'cpd00002' }); + expect(structures.get('cpd00003')).toEqual({ + id: 'cpd00003', + smiles: 'O', + inchi: 'InChI=1S/H2O/h1H2', + inchikey: 'XLYOFNOQVPJJNP-UHFFFAOYSA-N', + svg: "", + }); + expect(structures.has('unrequested')).toBe(false); + }); + + it('chunks 250 ids into three requests and merges results', async () => { + const api = await loadStructuresApi(); + const ids = Array.from({ length: 250 }, (_, index) => `cpd${index}`); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((url) => { + const query = new URL(String(url)).searchParams.get('q') ?? ''; + const id = query.match(/id:(cpd\d+)/)?.[1]; + return Promise.resolve(solrResponse({ response: { docs: id ? [{ id, inchi: 'InChI=1S/H2O/h1H2' }] : [] } })); + }); + + const structures = await api.getStructuresByIds(ids); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(structures.size).toBe(3); + expect(structures.get('cpd0')?.inchi).toBe('InChI=1S/H2O/h1H2'); + }); +}); From ebc50846b6a0b3f0ef6ca82a7bc2eae903d8d245 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 14:03:01 -0500 Subject: [PATCH 23/34] feat(biochem): derive local atom identity from raw InChI canonical order Atom-mapping references are 1-based positions in the InChI canonical order, which differs from the SMILES order the renderer draws in. Parse the InChI formula and connection layers, enumerate every element-preserving isomorphism onto the local heavy-atom graph exhaustively, and colour an atom only when every canonical index it could denote belongs to one mapping group. --- lib/utils/atomOrbitColors.ts | 256 +++++++++++++++++++ lib/utils/inchiAtomOrder.ts | 312 +++++++++++++++++++++++ tests/unit/utils/atomOrbitColors.test.ts | 63 +++++ tests/unit/utils/inchiAtomOrder.test.ts | 102 ++++++++ 4 files changed, 733 insertions(+) create mode 100644 lib/utils/atomOrbitColors.ts create mode 100644 lib/utils/inchiAtomOrder.ts create mode 100644 tests/unit/utils/atomOrbitColors.test.ts create mode 100644 tests/unit/utils/inchiAtomOrder.test.ts diff --git a/lib/utils/atomOrbitColors.ts b/lib/utils/atomOrbitColors.ts new file mode 100644 index 00000000..051bd3c4 --- /dev/null +++ b/lib/utils/atomOrbitColors.ts @@ -0,0 +1,256 @@ +import type { AtomMappingPair, AtomRef } from './atomMapping'; +import { MAPPING_PALETTE } from './atomMappingColors'; +import { + buildInchiAtomOrbits, + canonicalIndexForElementRef, + type HeavyAtomGraph, +} from './inchiAtomOrder'; + +export type MappingPrecision = 'exact-atom' | 'symmetry-orbit' | 'element-block' | 'unresolved'; + +export interface CompoundStructureInput { + readonly compoundId: string; + readonly inchi?: string; + readonly graph?: HeavyAtomGraph; +} + +export interface OrbitMappingGroup { + readonly groupId: string; + readonly color: string; + readonly elements: readonly string[]; + readonly compoundIds: readonly string[]; + readonly refCount: number; + readonly hasSymmetryGroup: boolean; +} + +export interface CompoundColorResult { + readonly compoundId: string; + readonly precision: MappingPrecision; + readonly reason?: string; + readonly atomColors: Readonly>; + readonly bondColors: Readonly>; + readonly atomGroups: Readonly>; + readonly elementClaims: Readonly>; + readonly coloredAtomCount: number; + readonly totalAtomCount: number; + readonly droppedRefCount: number; +} + +export interface AtomOrbitColorPlan { + readonly groups: readonly OrbitMappingGroup[]; + readonly compounds: Readonly>; + readonly precisionSummary: Readonly>; +} + +interface InternalGroup extends OrbitMappingGroup { + readonly refs: readonly AtomRef[]; +} + +const EMPTY_RESULT: CompoundColorResult = { + compoundId: '', precision: 'unresolved', reason: 'no-mapping', atomColors: {}, bondColors: {}, atomGroups: {}, + elementClaims: {}, coloredAtomCount: 0, totalAtomCount: 0, droppedRefCount: 0, +}; + +function validRef(value: unknown): value is AtomRef { + if (!value || typeof value !== 'object') return false; + const ref = value as AtomRef; + return typeof ref.compoundId === 'string' && ref.compoundId.length > 0 + && typeof ref.element === 'string' && ref.element.length > 0 + && Number.isSafeInteger(ref.index) && ref.index > 0; +} + +function refKey(ref: AtomRef): string { + return `${ref.compoundId}|${ref.element}|${ref.index}`; +} + +function validGraph(value: unknown): value is HeavyAtomGraph { + if (!value || typeof value !== 'object') return false; + const graph = value as HeavyAtomGraph; + return Array.isArray(graph.elements) && graph.elements.every((element) => typeof element === 'string') + && Array.isArray(graph.bonds); +} + +function mappingGroups(pairs: unknown): InternalGroup[] { + const parent = new Map(); + const firstSeen = new Map(); + const refs = new Map(); + const symmetry = new Set(); + let sequence = 0; + const find = (key: string): string => { + const current = parent.get(key); + if (!current || current === key) return key; + const root = find(current); + parent.set(key, root); + return root; + }; + const join = (left: string, right: string): void => { + const leftRoot = find(left); + const rightRoot = find(right); + if (leftRoot !== rightRoot) parent.set(rightRoot, leftRoot); + }; + + for (const candidate of Array.isArray(pairs) ? pairs : []) { + const pair = candidate as Partial | null; + const pairRefs = [ + ...(Array.isArray(pair?.leftAtoms) ? pair.leftAtoms : []), + ...(Array.isArray(pair?.rightAtoms) ? pair.rightAtoms : []), + ].filter(validRef); + const keys: string[] = []; + for (const ref of pairRefs) { + const key = refKey(ref); + if (!parent.has(key)) { + parent.set(key, key); + firstSeen.set(key, sequence++); + refs.set(key, { compoundId: ref.compoundId, element: ref.element, index: ref.index }); + } + keys.push(key); + } + for (let index = 1; index < keys.length; index += 1) join(keys[0], keys[index]); + if (pair?.hasSymmetryGroup === true) for (const key of keys) symmetry.add(key); + } + + const members = new Map(); + for (const key of parent.keys()) { + const root = find(key); + const bucket = members.get(root) ?? []; + bucket.push(key); + members.set(root, bucket); + } + return Array.from(members.values()) + .sort((left, right) => Math.min(...left.map((key) => firstSeen.get(key)!)) - Math.min(...right.map((key) => firstSeen.get(key)!))) + .map((keys, index) => { + const groupRefs = keys.sort((left, right) => firstSeen.get(left)! - firstSeen.get(right)!).map((key) => refs.get(key)!); + return { + groupId: `g${index + 1}`, + color: MAPPING_PALETTE[index % MAPPING_PALETTE.length] ?? '', + elements: Array.from(new Set(groupRefs.map((ref) => ref.element))).sort(), + compoundIds: Array.from(new Set(groupRefs.map((ref) => ref.compoundId))).sort(), + refCount: groupRefs.length, + hasSymmetryGroup: keys.some((key) => symmetry.has(key)), + refs: groupRefs, + }; + }); +} + +function baseResult(compoundId: string, graph: HeavyAtomGraph | undefined, precision: MappingPrecision, reason?: string): CompoundColorResult { + return { + compoundId, precision, ...(reason ? { reason } : {}), atomColors: {}, bondColors: {}, atomGroups: {}, elementClaims: {}, + coloredAtomCount: 0, totalAtomCount: graph?.elements.length ?? 0, droppedRefCount: 0, + }; +} + +function colorBonds(graph: HeavyAtomGraph, atomGroups: Record, groups: Map): Record { + const bondColors: Record = {}; + for (const [index, bond] of graph.bonds.entries()) { + if (!Array.isArray(bond) || bond.length !== 2) continue; + const [left, right] = bond; + if (!Number.isInteger(left) || !Number.isInteger(right)) continue; + const groupId = atomGroups[left]; + if (groupId && groupId === atomGroups[right]) bondColors[index] = groups.get(groupId)?.color ?? ''; + } + return bondColors; +} + +function resolveWithOrbits(compoundId: string, graph: HeavyAtomGraph, groups: InternalGroup[], result: Extract, { ok: true }>): CompoundColorResult { + const groupById = new Map(groups.map((group) => [group.groupId, group])); + const canonicalSets = new Map>(); + let droppedRefCount = 0; + for (const group of groups) { + const indices = new Set(); + for (const ref of group.refs.filter((item) => item.compoundId === compoundId)) { + const canonical = canonicalIndexForElementRef(result.canonicalElements, ref.element, ref.index); + if (canonical === undefined) droppedRefCount += 1; + else indices.add(canonical); + } + canonicalSets.set(group.groupId, indices); + } + const candidates = graph.elements.map(() => new Set()); + result.orbits.forEach((orbit, canonical) => orbit.forEach((local) => candidates[local]?.add(canonical + 1))); + const atomGroups: Record = {}; + const atomColors: Record = {}; + candidates.forEach((candidateSet, atom) => { + if (candidateSet.size === 0) return; + const matches = groups.filter((group) => candidateSet.size > 0 + && Array.from(candidateSet).every((canonical) => canonicalSets.get(group.groupId)?.has(canonical))); + if (matches.length === 1) { + atomGroups[atom] = matches[0].groupId; + atomColors[atom] = matches[0].color; + } + }); + return { + compoundId, precision: result.exact ? 'exact-atom' : 'symmetry-orbit', atomColors, + bondColors: colorBonds(graph, atomGroups, groupById), atomGroups, elementClaims: {}, + coloredAtomCount: Object.keys(atomColors).length, totalAtomCount: graph.elements.length, droppedRefCount, + }; +} + +function degrade(compoundId: string, graph: HeavyAtomGraph, groups: InternalGroup[], carriedReason: string): CompoundColorResult { + const groupById = new Map(groups.map((group) => [group.groupId, group])); + const atomGroups: Record = {}; + const atomColors: Record = {}; + const elementClaims: Record = {}; + let denialReason: string | undefined; + for (const element of Array.from(new Set(graph.elements))) { + const matching = groups.flatMap((group) => group.refs.filter((ref) => ref.compoundId === compoundId && ref.element === element) + .map((ref) => ({ ref, group }))); + if (matching.length === 0) continue; + const groupIds = new Set(matching.map(({ group }) => group.groupId)); + if (groupIds.size !== 1) { + denialReason ??= 'merged-groups'; + continue; + } + const indices = new Set(matching.map(({ ref }) => ref.index)); + const count = graph.elements.filter((item) => item === element).length; + if (indices.size !== count) { + denialReason ??= 'partial-coverage'; + continue; + } + const groupId = matching[0].group.groupId; + elementClaims[element] = groupId; + graph.elements.forEach((item, index) => { + if (item === element) { + atomGroups[index] = groupId; + atomColors[index] = matching[0].group.color; + } + }); + } + const claimed = Object.keys(elementClaims).length > 0; + return { + compoundId, precision: claimed ? 'element-block' : 'unresolved', ...(!claimed ? { reason: denialReason ?? carriedReason } : {}), + atomColors, bondColors: colorBonds(graph, atomGroups, groupById), atomGroups, elementClaims, + coloredAtomCount: Object.keys(atomColors).length, totalAtomCount: graph.elements.length, droppedRefCount: 0, + }; +} + +export function buildAtomOrbitColorPlan(pairs: readonly AtomMappingPair[], structures: readonly CompoundStructureInput[]): AtomOrbitColorPlan { + try { + const groups = mappingGroups(pairs); + const compounds: Record = {}; + const groupRefs = new Set(groups.flatMap((group) => group.refs.map((ref) => ref.compoundId))); + for (const structure of Array.isArray(structures) ? structures : []) { + if (!structure || typeof structure.compoundId !== 'string') continue; + const { compoundId } = structure; + const graph = validGraph(structure.graph) ? structure.graph : undefined; + if (!groupRefs.has(compoundId)) compounds[compoundId] = baseResult(compoundId, graph, 'unresolved', 'no-mapping'); + else if (!graph) compounds[compoundId] = baseResult(compoundId, undefined, 'unresolved', 'no-structure'); + else if (typeof structure.inchi !== 'string') compounds[compoundId] = degrade(compoundId, graph, groups, 'no-inchi'); + else { + const orbit = buildInchiAtomOrbits(structure.inchi, graph); + compounds[compoundId] = orbit.ok + ? resolveWithOrbits(compoundId, graph, groups, orbit) + : degrade(compoundId, graph, groups, orbit.reason); + } + } + const precisionSummary: Record = { + 'exact-atom': 0, 'symmetry-orbit': 0, 'element-block': 0, unresolved: 0, + }; + Object.values(compounds).forEach((result) => { precisionSummary[result.precision] += 1; }); + return { groups: groups.map(({ refs, ...group }) => { void refs; return group; }), compounds, precisionSummary }; + } catch { + return { groups: [], compounds: {}, precisionSummary: { 'exact-atom': 0, 'symmetry-orbit': 0, 'element-block': 0, unresolved: 0 } }; + } +} + +export function compoundColorResult(plan: AtomOrbitColorPlan, compoundId: string): CompoundColorResult { + return plan?.compounds?.[compoundId] ?? EMPTY_RESULT; +} diff --git a/lib/utils/inchiAtomOrder.ts b/lib/utils/inchiAtomOrder.ts new file mode 100644 index 00000000..0273e1f1 --- /dev/null +++ b/lib/utils/inchiAtomOrder.ts @@ -0,0 +1,312 @@ +/** + * Reconstruct safe InChI-canonical heavy-atom references for a local molecular + * graph. This module deliberately uses only the InChI formula and `/c` layer: + * RDKit MinimalLib cannot expose InChI canonical atom order. + */ + +/** A heavy-atom molecular graph in local (RDKit / SMILES) index space, 0-based. */ +export interface HeavyAtomGraph { + /** element symbol per 0-based atom index, e.g. ['O', 'P', 'O', 'O', 'O'] */ + elements: string[]; + /** undirected bonds as 0-based index pairs; order and duplication are tolerated */ + bonds: Array<[number, number]>; +} + +export type InchiOrbitFailure = + | 'no-inchi' + | 'multi-component' + | 'unsupported-inchi' + | 'formula-parse-failed' + | 'connection-parse-failed' + | 'atom-count-mismatch' + | 'element-count-mismatch' + | 'bond-count-mismatch' + | 'too-large' + | 'no-isomorphism' + | 'search-exhausted'; + +export interface InchiOrbitOptions { + maxAtoms?: number; + maxSolutions?: number; + maxSteps?: number; +} + +export interface InchiOrbitSuccess { + ok: true; + /** element symbol per 1-based canonical index; canonicalElements[0] is canonical atom 1 */ + canonicalElements: string[]; + /** orbits[c] = sorted, de-duplicated local 0-based indices canonical atom (c+1) may denote */ + orbits: number[][]; + /** number of isomorphisms found */ + solutionCount: number; + /** true when every orbit has exactly one member */ + exact: boolean; +} + +export type InchiOrbitResult = InchiOrbitSuccess | { ok: false; reason: InchiOrbitFailure }; + +/** + * Return the heavy elements in InChI Hill canonical order. `undefined` means + * the formula is not wholly composed of element/count pairs or has no heavy atom. + */ +export function hillCanonicalElements(formulaLayer: string): string[] | undefined { + if (typeof formulaLayer !== 'string' || formulaLayer.includes('.')) return undefined; + + const counts = new Map(); + const elementPattern = /([A-Z][a-z]?)(\d*)/y; + let position = 0; + let parsedAny = false; + while (position < formulaLayer.length) { + elementPattern.lastIndex = position; + const match = elementPattern.exec(formulaLayer); + if (!match || match.index !== position) return undefined; + parsedAny = true; + const count = match[2] ? Number.parseInt(match[2], 10) : 1; + if (!Number.isSafeInteger(count) || count < 1) return undefined; + if (match[1] !== 'H') counts.set(match[1], (counts.get(match[1]) ?? 0) + count); + position = elementPattern.lastIndex; + } + + if (!parsedAny || counts.size === 0) return undefined; + const symbols = Array.from(counts.keys()).sort((a, b) => { + if (a === 'C') return -1; + if (b === 'C') return 1; + return a.localeCompare(b); + }); + return symbols.flatMap((symbol) => Array.from({ length: counts.get(symbol) ?? 0 }, () => symbol)); +} + +/** Parse an InChI `/c` connection string into distinct 1-based unordered edges. */ +export function parseInchiConnections(connectionLayer: string): Array<[number, number]> | undefined { + if (typeof connectionLayer !== 'string' || /[;*?]/.test(connectionLayer)) return undefined; + + const tokens: Array = []; + for (let index = 0; index < connectionLayer.length;) { + const character = connectionLayer[index]; + if (/\d/.test(character)) { + let end = index + 1; + while (end < connectionLayer.length && /\d/.test(connectionLayer[end])) end += 1; + const value = Number.parseInt(connectionLayer.slice(index, end), 10); + if (!Number.isSafeInteger(value)) return undefined; + tokens.push(value); + index = end; + } else if (character === '-' || character === '(' || character === ')' || character === ',') { + tokens.push(character); + index += 1; + } else { + return undefined; + } + } + + let previous: number | null = null; + const stack: Array = []; + const edges = new Map(); + for (const token of tokens) { + if (typeof token === 'number') { + if (previous !== null) { + const left = Math.min(previous, token); + const right = Math.max(previous, token); + if (left === right) return undefined; + edges.set(`${left}:${right}`, [left, right]); + } + previous = token; + } else if (token === '(') { + stack.push(previous); + } else if (token === ',') { + if (stack.length === 0) return undefined; + previous = stack[stack.length - 1]; + } else if (token === ')') { + if (stack.length === 0) return undefined; + previous = stack.pop() ?? null; + } + } + return stack.length === 0 ? Array.from(edges.values()) : undefined; +} + +function failure(reason: InchiOrbitFailure): InchiOrbitResult { + return { ok: false, reason }; +} + +function option(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : fallback; +} + +function countElements(elements: readonly string[]): Map { + const counts = new Map(); + for (const element of elements) counts.set(element, (counts.get(element) ?? 0) + 1); + return counts; +} + +function sameCounts(left: Map, right: Map): boolean { + return left.size === right.size && Array.from(left.entries()).every(([element, count]) => right.get(element) === count); +} + +function graphEdges(graph: unknown, atomCount: number): Set | undefined { + if (!graph || typeof graph !== 'object' || !Array.isArray((graph as HeavyAtomGraph).bonds)) return undefined; + const edges = new Set(); + for (const bond of (graph as HeavyAtomGraph).bonds) { + if (!Array.isArray(bond) || bond.length !== 2) return undefined; + const [first, second] = bond; + if (!Number.isInteger(first) || !Number.isInteger(second) + || first < 0 || second < 0 || first >= atomCount || second >= atomCount || first === second) { + return undefined; + } + edges.add(first < second ? `${first}:${second}` : `${second}:${first}`); + } + return edges; +} + +/** + * Build complete, element-preserving graph-isomorphism orbits. If a configured + * search cap is reached, returns a failure rather than a partial (unsound) orbit. + */ +export function buildInchiAtomOrbits( + inchi: string | undefined | null, + graph: HeavyAtomGraph, + options?: InchiOrbitOptions, +): InchiOrbitResult { + try { + if (typeof inchi !== 'string' || !inchi.startsWith('InChI=')) return failure('no-inchi'); + const layers = inchi.split('/'); + const formula = layers[1]; + if (!formula) return failure('formula-parse-failed'); + if (formula.includes('.')) return failure('multi-component'); + + const canonicalElements = hillCanonicalElements(formula); + if (!canonicalElements) { + return /^[a-z]/.test(formula) ? failure('unsupported-inchi') : failure('formula-parse-failed'); + } + const maxAtoms = option(options?.maxAtoms, 80); + if (canonicalElements.length > maxAtoms) return failure('too-large'); + + const connectionLayer = layers.slice(2).find((layer) => layer.startsWith('c')); + if (connectionLayer?.includes(';') || connectionLayer?.includes('*')) return failure('multi-component'); + if (connectionLayer?.includes('?')) return failure('unsupported-inchi'); + + const rawElements = graph && typeof graph === 'object' ? (graph as HeavyAtomGraph).elements : undefined; + if (!Array.isArray(rawElements) || rawElements.some((element) => typeof element !== 'string')) { + return failure('atom-count-mismatch'); + } + if (canonicalElements.length !== rawElements.length) return failure('atom-count-mismatch'); + if (!sameCounts(countElements(canonicalElements), countElements(rawElements))) { + return failure('element-count-mismatch'); + } + + let canonicalEdges: Array<[number, number]>; + if (connectionLayer === undefined) { + if (canonicalElements.length !== 1) return failure('connection-parse-failed'); + canonicalEdges = []; + } else { + const parsed = parseInchiConnections(connectionLayer.slice(1)); + if (!parsed || parsed.some(([left, right]) => left < 1 || right < 1 + || left > canonicalElements.length || right > canonicalElements.length)) { + return failure('connection-parse-failed'); + } + canonicalEdges = parsed; + } + + const localEdges = graphEdges(graph, rawElements.length); + if (!localEdges || canonicalEdges.length !== localEdges.size) return failure('bond-count-mismatch'); + + const canonicalAdjacency = Array.from({ length: canonicalElements.length }, () => new Set()); + for (const [left, right] of canonicalEdges) { + canonicalAdjacency[left - 1].add(right - 1); + canonicalAdjacency[right - 1].add(left - 1); + } + const localAdjacency = Array.from({ length: rawElements.length }, () => new Set()); + for (const key of localEdges) { + const [left, right] = key.split(':').map(Number); + localAdjacency[left].add(right); + localAdjacency[right].add(left); + } + + const order = canonicalElements.map((_, index) => index).sort((left, right) => { + const degreeDifference = canonicalAdjacency[right].size - canonicalAdjacency[left].size; + if (degreeDifference) return degreeDifference; + const leftRarity = canonicalElements.filter((element) => element === canonicalElements[left]).length; + const rightRarity = canonicalElements.filter((element) => element === canonicalElements[right]).length; + return leftRarity - rightRarity || left - right; + }); + const candidates = canonicalElements.map((element, canonical) => rawElements + .map((local, index) => ({ local, index })) + .filter(({ local, index }) => local === element && localAdjacency[index].size === canonicalAdjacency[canonical].size) + .map(({ index }) => index)); + + const maxSteps = option(options?.maxSteps, 500_000); + const maxSolutions = option(options?.maxSolutions, 512); + const mapping = Array(canonicalElements.length).fill(-1); + const used = new Set(); + const orbitSets = canonicalElements.map(() => new Set()); + let steps = 0; + let solutionCount = 0; + let exhausted = false; + + const search = (depth: number): void => { + steps += 1; + if (steps > maxSteps || exhausted) { + exhausted = true; + return; + } + if (depth === order.length) { + solutionCount += 1; + if (solutionCount > maxSolutions) { + exhausted = true; + return; + } + mapping.forEach((local, canonical) => orbitSets[canonical].add(local)); + return; + } + const canonical = order[depth]; + for (const local of candidates[canonical]) { + if (used.has(local)) continue; + let consistent = true; + for (let other = 0; other < mapping.length; other += 1) { + if (mapping[other] === -1) continue; + if (canonicalAdjacency[canonical].has(other) !== localAdjacency[local].has(mapping[other])) { + consistent = false; + break; + } + } + if (!consistent) continue; + mapping[canonical] = local; + used.add(local); + search(depth + 1); + used.delete(local); + mapping[canonical] = -1; + if (exhausted) return; + } + }; + search(0); + if (exhausted) return failure('search-exhausted'); + if (solutionCount === 0) return failure('no-isomorphism'); + + const orbits = orbitSets.map((orbit) => Array.from(orbit).sort((left, right) => left - right)); + return { + ok: true, + canonicalElements, + orbits, + solutionCount, + exact: orbits.every((orbit) => orbit.length === 1), + }; + } catch { + return failure('connection-parse-failed'); + } +} + +/** Resolve an element-local mapping reference to its 1-based canonical index. */ +export function canonicalIndexForElementRef( + canonicalElements: string[], + element: string, + oneBasedWithinElement: number, +): number | undefined { + if (!Array.isArray(canonicalElements) || !Number.isInteger(oneBasedWithinElement) || oneBasedWithinElement < 1) { + return undefined; + } + let seen = 0; + for (let index = 0; index < canonicalElements.length; index += 1) { + if (canonicalElements[index] === element && ++seen === oneBasedWithinElement) return index + 1; + } + return undefined; +} diff --git a/tests/unit/utils/atomOrbitColors.test.ts b/tests/unit/utils/atomOrbitColors.test.ts new file mode 100644 index 00000000..44edc7f8 --- /dev/null +++ b/tests/unit/utils/atomOrbitColors.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { parseAtomMappings, type AtomMappingPair } from '@/lib/utils/atomMapping'; +import { MAPPING_PALETTE } from '@/lib/utils/atomMappingColors'; +import { buildAtomOrbitColorPlan } from '@/lib/utils/atomOrbitColors'; + +const pair = (left: string, right: string): AtomMappingPair => ({ + left: { compoundId: left, element: 'O', index: 1 }, right: { compoundId: right, element: 'O', index: 1 }, + leftAtoms: [{ compoundId: left, element: 'O', index: 1 }], rightAtoms: [{ compoundId: right, element: 'O', index: 1 }], hasSymmetryGroup: false, raw: '', +}); + +describe('buildAtomOrbitColorPlan', () => { + it('constructs transitive groups in first-appearance order and cycles palette', () => { + const plan = buildAtomOrbitColorPlan([pair('cpd00001', 'cpd00002'), pair('cpd00002', 'cpd00003'), pair('cpd00004', 'cpd00005')], []); + expect(plan.groups.map((group) => group.groupId)).toEqual(['g1', 'g2']); + expect(plan.groups.map((group) => group.color)).toEqual([MAPPING_PALETTE[0], MAPPING_PALETTE[1]]); + const many = buildAtomOrbitColorPlan(Array.from({ length: MAPPING_PALETTE.length + 1 }, (_, i) => pair(`cpd${String(i + 100).padStart(5, '0')}`, `cpd${String(i + 200).padStart(5, '0')}`)), []); + expect(many.groups.map((group) => group.color)).toEqual(Array.from({ length: MAPPING_PALETTE.length + 1 }, (_, i) => MAPPING_PALETTE[i % MAPPING_PALETTE.length])); + }); + + it('colours exact water and refuses unmapped and missing structures', () => { + const pairs = parseAtomMappings(['cpd00001:O#1=cpd00002:O#1']); + const plan = buildAtomOrbitColorPlan(pairs, [ + { compoundId: 'cpd00001', inchi: 'InChI=1S/H2O/h1H2', graph: { elements: ['O'], bonds: [] } }, + { compoundId: 'cpd00067', inchi: 'InChI=1S/p+1', graph: { elements: ['H'], bonds: [] } }, + { compoundId: 'cpd00002' }, + ]); + expect(plan.compounds.cpd00001).toMatchObject({ precision: 'exact-atom', coloredAtomCount: 1, atomColors: { 0: plan.groups[0].color }, bondColors: {} }); + expect(plan.compounds.cpd00067).toMatchObject({ precision: 'unresolved', reason: 'no-mapping' }); + expect(plan.compounds.cpd00002).toMatchObject({ precision: 'unresolved', reason: 'no-structure' }); + }); + + it('uses InChI orbits, not local mapping positions, for cpd00009', () => { + const pairs = parseAtomMappings([ + 'cpd00001:O#1=cpd00009:(O#1;O#2;O#3;O#4)', + 'cpd00012:(O#1;O#2;O#3;O#4;O#5;O#6)=cpd00009:(O#1;O#2;O#3;O#4)', + 'cpd00012:(P#1;P#2)=cpd00009:P#1', + 'cpd00012:O#7=cpd00009:(O#1;O#2;O#3;O#4)', + ]); + const plan = buildAtomOrbitColorPlan(pairs, [{ compoundId: 'cpd00009', inchi: 'InChI=1S/H3O4P/c1-5(2,3)4', graph: { elements: ['O', 'P', 'O', 'O', 'O'], bonds: [[0, 1], [1, 2], [1, 3], [1, 4]] } }]); + const result = plan.compounds.cpd00009; + expect(result).toMatchObject({ precision: 'symmetry-orbit', coloredAtomCount: 5, bondColors: {} }); + expect([result.atomColors[0], result.atomColors[2], result.atomColors[3], result.atomColors[4]]).toEqual([plan.groups[0].color, plan.groups[0].color, plan.groups[0].color, plan.groups[0].color]); + expect(result.atomColors[1]).toBe(plan.groups[1].color); + }); + + it('degrades only fully covered one-group elements and applies the bond rule', () => { + const good = buildAtomOrbitColorPlan(parseAtomMappings(['cpd00001:(O#1;O#2)=cpd00002:(O#1;O#2)']), [{ compoundId: 'cpd00001', graph: { elements: ['O', 'O'], bonds: [[0, 1]] } }]); + expect(good.compounds.cpd00001).toMatchObject({ precision: 'element-block', elementClaims: { O: 'g1' }, atomColors: { 0: MAPPING_PALETTE[0], 1: MAPPING_PALETTE[0] }, bondColors: { 0: MAPPING_PALETTE[0] } }); + const merged = buildAtomOrbitColorPlan([pair('cpd00001', 'cpd00002'), { ...pair('cpd00001', 'cpd00003'), left: { compoundId: 'cpd00001', element: 'O', index: 2 }, leftAtoms: [{ compoundId: 'cpd00001', element: 'O', index: 2 }] }], [{ compoundId: 'cpd00001', graph: { elements: ['O', 'O'], bonds: [] } }]); + expect(merged.compounds.cpd00001).toMatchObject({ precision: 'unresolved', reason: 'merged-groups' }); + const partial = buildAtomOrbitColorPlan(parseAtomMappings(['cpd00001:(O#1;O#2)=cpd00002:(O#1;O#2)']), [{ compoundId: 'cpd00001', graph: { elements: ['O', 'O', 'O', 'O'], bonds: [] } }]); + expect(partial.compounds.cpd00001).toMatchObject({ precision: 'unresolved', reason: 'partial-coverage' }); + }); + + it('drops invalid references, tolerates invalid inputs, and is deterministic', () => { + const malformed = [{ ...pair('cpd00001', 'cpd00002'), leftAtoms: [{ compoundId: 'cpd00001', element: 'O', index: 99 }, { compoundId: 'cpd00001', element: 'O', index: 0 }, { compoundId: 'cpd00001', element: 'X', index: Number.NaN }], rightAtoms: [{ compoundId: 'cpd00002', element: 'O', index: 1 }] }]; + const input = [{ compoundId: 'cpd00001', inchi: 'InChI=1S/O4/c1-2-3-4', graph: { elements: ['O', 'O', 'O', 'O'], bonds: [[0, 1], [1, 2], [2, 3]] as Array<[number, number]> } }]; + const plan = buildAtomOrbitColorPlan(malformed, input); + expect(plan.compounds.cpd00001).toMatchObject({ droppedRefCount: 1, coloredAtomCount: 0 }); + expect(buildAtomOrbitColorPlan(undefined as never, undefined as never)).toEqual({ groups: [], compounds: {}, precisionSummary: { 'exact-atom': 0, 'symmetry-orbit': 0, 'element-block': 0, unresolved: 0 } }); + expect(buildAtomOrbitColorPlan(malformed, input)).toEqual(plan); + }); +}); diff --git a/tests/unit/utils/inchiAtomOrder.test.ts b/tests/unit/utils/inchiAtomOrder.test.ts new file mode 100644 index 00000000..831dcef0 --- /dev/null +++ b/tests/unit/utils/inchiAtomOrder.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + buildInchiAtomOrbits, + canonicalIndexForElementRef, + hillCanonicalElements, + parseInchiConnections, +} from '@/lib/utils/inchiAtomOrder'; + +const phosphateInchi = 'InChI=1S/H3O4P/c1-5(2,3)4/h(H3,1,2,3,4)'; +const phosphateGraph = { + elements: ['O', 'P', 'O', 'O', 'O'], + bonds: [[0, 1], [1, 2], [1, 3], [1, 4]] as Array<[number, number]>, +}; + +function edgeSet(edges: Array<[number, number]> | undefined): string[] { + return (edges ?? []).map(([left, right]) => `${left}-${right}`).sort(); +} + +describe('hillCanonicalElements', () => { + it('expands formulae in canonical Hill heavy-atom order', () => { + expect(hillCanonicalElements('H3O4P')).toEqual(['O', 'O', 'O', 'O', 'P']); + expect(hillCanonicalElements('C5H9NO4')).toEqual(['C', 'C', 'C', 'C', 'C', 'N', 'O', 'O', 'O', 'O']); + expect(hillCanonicalElements('CO2')).toEqual(['C', 'O', 'O']); + expect(hillCanonicalElements('C2H3ClO')).toEqual(['C', 'C', 'Cl', 'O']); + expect(hillCanonicalElements('H2O')).toEqual(['O']); + }); +}); + +describe('parseInchiConnections', () => { + it('parses both documented branch forms', () => { + expect(edgeSet(parseInchiConnections('1-5(2,3)4'))).toEqual(['1-5', '2-5', '3-5', '4-5']); + expect(edgeSet(parseInchiConnections('6-3(5(9)10)1-2-4(7)8'))).toEqual([ + '1-2', '1-3', '2-4', '3-5', '3-6', '4-7', '4-8', '5-10', '5-9', + ]); + }); +}); + +describe('buildInchiAtomOrbits', () => { + it('maps cpd00009 phosphorus to its RDKit local index without false colouring', () => { + const result = buildInchiAtomOrbits(phosphateInchi, phosphateGraph); + expect(result).toMatchObject({ + ok: true, + solutionCount: 24, + canonicalElements: ['O', 'O', 'O', 'O', 'P'], + exact: false, + }); + if (!result.ok) return; + expect(result.orbits[4]).toEqual([1]); + expect(result.orbits.slice(0, 4)).toEqual([[0, 2, 3, 4], [0, 2, 3, 4], [0, 2, 3, 4], [0, 2, 3, 4]]); + expect(canonicalIndexForElementRef(result.canonicalElements, 'P', 1)).toBe(5); + expect(result.orbits[canonicalIndexForElementRef(result.canonicalElements, 'P', 1)! - 1]).toEqual([1]); + }); + + it('handles CO2 symmetry', () => { + const result = buildInchiAtomOrbits('InChI=1S/CO2/c2-1-3', { + elements: ['O', 'C', 'O'], bonds: [[0, 1], [1, 2]], + }); + expect(result).toMatchObject({ ok: true, solutionCount: 2 }); + if (result.ok) expect(result.orbits).toEqual([[1], [0, 2], [0, 2]]); + }); + + it('handles a single heavy atom with no /c layer', () => { + expect(buildInchiAtomOrbits('InChI=1S/H2O/h1H2', { elements: ['O'], bonds: [] })) + .toEqual(expect.objectContaining({ ok: true, orbits: [[0]], exact: true })); + }); + + it('reports a genuinely exact graph', () => { + const result = buildInchiAtomOrbits('InChI=1S/CNO/c1-2-3', { + elements: ['N', 'C', 'O'], bonds: [[0, 1], [0, 2]], + }); + expect(result).toEqual(expect.objectContaining({ ok: true, exact: true, orbits: [[1], [0], [2]] })); + }); + + it('returns each exact failure reason', () => { + const oneO = { elements: ['O'], bonds: [] as Array<[number, number]> }; + const cases: Array<[string | undefined, typeof oneO, object | undefined, string]> = [ + [undefined, oneO, undefined, 'no-inchi'], + ['', oneO, undefined, 'no-inchi'], + ['XLYOFNOQVPJJNP-UHFFFAOYSA-N', oneO, undefined, 'no-inchi'], + ['InChI=1S/Na.Cl/c1-2', oneO, undefined, 'multi-component'], + ['InChI=1S/CO2/c1;2', oneO, undefined, 'multi-component'], + ['InChI=1S/CO2/c1?2', oneO, undefined, 'unsupported-inchi'], + ['InChI=1S/p+1', { elements: ['H'], bonds: [] }, undefined, 'unsupported-inchi'], + ['InChI=1S/CO2/c2-1-3', oneO, undefined, 'atom-count-mismatch'], + ['InChI=1S/CO2/c2-1-3', { elements: ['C', 'N', 'O'], bonds: [[0, 1], [1, 2]] }, undefined, 'element-count-mismatch'], + ['InChI=1S/CO2/c2-1-3', { elements: ['O', 'C', 'O'], bonds: [] }, undefined, 'bond-count-mismatch'], + ['InChI=1S/CO2/c2-1-3', { elements: ['O', 'C', 'O'], bonds: [[0, 3], [0, 1]] }, undefined, 'bond-count-mismatch'], + ['InChI=1S/C2NO/c1-2-3-4', { elements: ['C', 'N', 'C', 'O'], bonds: [[0, 1], [1, 2], [2, 3]] }, undefined, 'no-isomorphism'], + [phosphateInchi, phosphateGraph, { maxSteps: 1 }, 'search-exhausted'], + [phosphateInchi, phosphateGraph, { maxSolutions: 2 }, 'search-exhausted'], + [phosphateInchi, phosphateGraph, { maxAtoms: 2 }, 'too-large'], + ]; + for (const [inchi, graph, options, reason] of cases) { + expect(buildInchiAtomOrbits(inchi, graph, options)).toEqual({ ok: false, reason }); + } + }); + + it('is deterministic', () => { + expect(buildInchiAtomOrbits(phosphateInchi, phosphateGraph)) + .toEqual(buildInchiAtomOrbits(phosphateInchi, phosphateGraph)); + }); +}); From 59857cc9a74664cbba6f6bf4af42946fe1895b91 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 14:03:01 -0500 Subject: [PATCH 24/34] feat(biochem): colour reaction structures from InChI-derived atom orbits Fetch raw InChI and the precomputed depiction alongside each participant, report the local heavy-atom graph up from the renderer, and drive atom and bond colours from the orbit plan. The stored SVG is used only as an unhighlighted fallback depiction. --- components/ui/MoleculeRenderer.tsx | 59 +++++++++++++++--- components/ui/ReactionStructureEquation.tsx | 61 ++++++++++++++----- .../unit/components/MoleculeRenderer.test.tsx | 26 ++++++++ .../ReactionStructureEquation.test.tsx | 59 ++++++++++++++++-- 4 files changed, 175 insertions(+), 30 deletions(-) create mode 100644 tests/unit/components/MoleculeRenderer.test.tsx diff --git a/components/ui/MoleculeRenderer.tsx b/components/ui/MoleculeRenderer.tsx index d4bfd554..b941dc62 100644 --- a/components/ui/MoleculeRenderer.tsx +++ b/components/ui/MoleculeRenderer.tsx @@ -6,7 +6,8 @@ import Skeleton from '@mui/material/Skeleton'; import Typography from '@mui/material/Typography'; import { getRDKit } from '@/lib/rdkit'; import { getCompoundImageUrl } from '@/lib/api/biochem'; -import { applyBondColors, buildMoleculeHighlightPlan, elementInventoryFromMolJson } from '@/lib/utils/moleculeHighlights'; +import { applyBondColors, buildMoleculeHighlightPlan, elementInventoryFromMolJson, elementSymbolForAtomicNumber } from '@/lib/utils/moleculeHighlights'; +import type { HeavyAtomGraph } from '@/lib/utils/inchiAtomOrder'; /** * Maps atom index (0-based) to a CSS color string. @@ -25,6 +26,12 @@ interface MoleculeRendererProps { elementColors?: Readonly>; /** Called after a successful RDKit parse with the molecule's element inventory. */ onInventory?: (inventory: Record) => void; + /** Optional per-bond color map, keyed by the RDKit graph bond-array index. */ + bondColors?: Record; + /** Called after a successful RDKit parse with the molecule's local heavy-atom graph. */ + onGraph?: (graph: HeavyAtomGraph) => void; + /** Plain stored SVG used only when a local RDKit SVG cannot be produced. */ + fallbackSvg?: string; width?: number; height?: number; alt?: string; @@ -38,6 +45,9 @@ export default function MoleculeRenderer({ atomColors, elementColors, onInventory, + bondColors, + onGraph, + fallbackSvg, width = 150, height = 150, alt, @@ -46,22 +56,33 @@ export default function MoleculeRenderer({ const [svgString, setSvgString] = useState(''); const atomColorsKey = useMemo(() => JSON.stringify(atomColors ?? {}), [atomColors]); const elementColorsKey = useMemo(() => JSON.stringify(elementColors ?? {}), [elementColors]); + const bondColorsKey = useMemo(() => JSON.stringify(bondColors ?? {}), [bondColors]); const onInventoryRef = useRef(onInventory); + const onGraphRef = useRef(onGraph); const atomColorsRef = useRef(atomColors); const elementColorsRef = useRef(elementColors); + const bondColorsRef = useRef(bondColors); useEffect(() => { onInventoryRef.current = onInventory; + onGraphRef.current = onGraph; atomColorsRef.current = atomColors; elementColorsRef.current = elementColors; - }, [onInventory, atomColors, elementColors]); + bondColorsRef.current = bondColors; + }, [onInventory, onGraph, atomColors, elementColors, bondColors]); useEffect(() => { let cancelled = false; if (!smiles) { - // No structural string from API; avoid rendering empty/transparent fallback images. - setState('hidden'); + // Stored SVG is already rendered and must not receive local highlight processing. + if (fallbackSvg) { + setSvgString(fallbackSvg); + setState('svg'); + } else { + // No structural string from API; avoid rendering empty/transparent fallback images. + setState('hidden'); + } return; } @@ -76,10 +97,21 @@ export default function MoleculeRenderer({ let molJson: unknown; const currentElementColors = elementColorsRef.current; const currentAtomColors = atomColorsRef.current; - if ((currentElementColors && Object.keys(currentElementColors).length > 0) || onInventoryRef.current) { + const currentBondColors = bondColorsRef.current; + if ((currentElementColors && Object.keys(currentElementColors).length > 0) || onInventoryRef.current || onGraphRef.current) { try { molJson = JSON.parse(mol.get_json()); onInventoryRef.current?.(elementInventoryFromMolJson(molJson)); + const molecule = (molJson as { molecules?: Array<{ atoms?: Array<{ z?: number }>; bonds?: Array<{ atoms?: readonly number[] }> }> }).molecules?.[0]; + if (molecule?.atoms && molecule.bonds) { + onGraphRef.current?.({ + elements: molecule.atoms.map((atom) => elementSymbolForAtomicNumber(atom.z ?? 6)), + bonds: molecule.bonds.flatMap((bond) => { + const [left, right] = bond.atoms ?? []; + return Number.isInteger(left) && Number.isInteger(right) ? [[left, right] as [number, number]] : []; + }), + }); + } } catch { molJson = undefined; } @@ -139,6 +171,9 @@ export default function MoleculeRenderer({ svg = mol.get_svg(width, height); } + if (currentBondColors && Object.keys(currentBondColors).length > 0) { + svg = applyBondColors(svg, currentBondColors); + } if (!cancelled) { setSvgString(svg); setState('svg'); @@ -148,18 +183,24 @@ export default function MoleculeRenderer({ mol.delete(); } } catch { - // Invalid SMILES or RDKit error — fall back to CDN PNG silently - if (!cancelled) setState('png'); + // Invalid SMILES or RDKit error — use a stored SVG before the CDN PNG fallback. + if (!cancelled && fallbackSvg) { + setSvgString(fallbackSvg); + setState('svg'); + } else if (!cancelled) setState('png'); } }) .catch(() => { - if (!cancelled) setState('png'); + if (!cancelled && fallbackSvg) { + setSvgString(fallbackSvg); + setState('svg'); + } else if (!cancelled) setState('png'); }); return () => { cancelled = true; }; - }, [smiles, atomColorsKey, elementColorsKey, width, height]); + }, [smiles, atomColorsKey, elementColorsKey, bondColorsKey, fallbackSvg, width, height]); if (state === 'loading') { return ( diff --git a/components/ui/ReactionStructureEquation.tsx b/components/ui/ReactionStructureEquation.tsx index 12b8816b..d1509860 100644 --- a/components/ui/ReactionStructureEquation.tsx +++ b/components/ui/ReactionStructureEquation.tsx @@ -10,6 +10,7 @@ import Skeleton from '@mui/material/Skeleton'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { getCompoundsForReaction } from '@/lib/api/biochem'; +import { getStructuresByIds, type CompoundStructure } from '@/lib/api/structures'; import { heavyAtomCount, isParsableFormula, parseFormulaInventory } from '@/lib/utils/chemicalFormula'; import type { AtomMappingPair } from '@/lib/utils/atomMapping'; import { @@ -17,6 +18,8 @@ import { elementColorsForCompound, type UnmappableReason, } from '@/lib/utils/atomMappingColors'; +import { buildAtomOrbitColorPlan, compoundColorResult } from '@/lib/utils/atomOrbitColors'; +import type { HeavyAtomGraph } from '@/lib/utils/inchiAtomOrder'; import type { AtomColors } from './MoleculeRenderer'; const MoleculeRenderer = dynamic(() => import('./MoleculeRenderer'), { @@ -45,6 +48,7 @@ type DisplayData = { name?: string; smiles?: string; formula?: string; charge?: const EMPTY_PARSED: ParsedEquation = { reactants: [], products: [], arrow: '⇒' }; const EMPTY_MAP = new Map(); +const EMPTY_STRUCTURES = new Map(); const REASON_TEXT: Record = { 'no-mapping': 'no mapping data', 'element-mismatch': 'element mismatch between sides', @@ -101,7 +105,9 @@ function confidenceColor(value: string): 'success' | 'warning' | 'default' { interface CompoundColumnProps { token: CompoundToken; data?: DisplayData; + structure?: CompoundStructure; atomColors?: AtomColors; + bondColors?: Record; elementColors?: Readonly>; mappingDescription?: string; mappingControls?: Readonly>; @@ -110,22 +116,27 @@ interface CompoundColumnProps { onClearHighlight: () => void; onSelectGroup: (groupId: string) => void; onInventory: (inventory: Inventory) => void; + onGraph: (graph: HeavyAtomGraph) => void; isLoading: boolean; } -function CompoundColumn({ token, data, atomColors, elementColors, mappingDescription, mappingControls, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup, onInventory, isLoading }: CompoundColumnProps) { - const drawStructure = Boolean(data?.smiles) && (!data?.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1); +function CompoundColumn({ token, data, structure, atomColors, bondColors, elementColors, mappingDescription, mappingControls, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup, onInventory, onGraph, isLoading }: CompoundColumnProps) { + const smiles = data?.smiles ?? structure?.smiles; + const drawStructure = Boolean(structure?.svg) || (Boolean(smiles) && (!data?.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1)); const label = data?.name || token.id; const metadata = [token.id, data?.formula, formatCharge(data?.charge)].filter(Boolean).join(' · '); const contents = isLoading ? ( ) : drawStructure ? ( {label} } {metadata} - {!isLoading && elementColors && - {Object.entries(elementColors).map(([element, color]) => { - const control = mappingControls?.[element]; - return control && onSelectGroup(control.groupId)} onMouseEnter={() => control && onHighlight(control.groupId)} onMouseLeave={onClearHighlight} onFocus={() => control && onHighlight(control.groupId)} onBlur={onClearHighlight} sx={{ display: 'flex', alignItems: 'center', gap: 0.25, border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', font: 'inherit' }}> + {!isLoading && Object.keys(mappingControls ?? {}).length > 0 && + {Object.entries(mappingControls ?? {}).map(([element, control]) => { + const color = control.color; + return onSelectGroup(control.groupId)} onMouseEnter={() => onHighlight(control.groupId)} onMouseLeave={onClearHighlight} onFocus={() => onHighlight(control.groupId)} onBlur={onClearHighlight} sx={{ display: 'flex', alignItems: 'center', gap: 0.25, border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', font: 'inherit' }}> ; @@ -165,23 +176,24 @@ function CompoundColumn({ token, data, atomColors, elementColors, mappingDescrip ); } -function EquationSide({ tokens, displayMap, atomMapping, useElementColors, plan, callbacks, isLoading, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup }: { - tokens: CompoundToken[]; displayMap: Map; - atomMapping?: ReactionAtomMapping; useElementColors: boolean; plan: ReturnType; - callbacks: Readonly void>>; +function EquationSide({ tokens, displayMap, structures, atomMapping, useElementColors, plan, orbitPlan, callbacks, graphCallbacks, isLoading, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup }: { + tokens: CompoundToken[]; displayMap: Map; structures: Map; + atomMapping?: ReactionAtomMapping; useElementColors: boolean; plan: ReturnType; orbitPlan: ReturnType; + callbacks: Readonly void>>; graphCallbacks: Readonly void>>; isLoading: boolean; highlightedGroup?: string; onHighlight: (groupId: string) => void; onClearHighlight: () => void; onSelectGroup: (groupId: string) => void; }) { return {tokens.map((token, index) => { const elementColors = useElementColors ? elementColorsForCompound(plan, token.id) : undefined; const colors = elementColors && Object.keys(elementColors).length > 0 ? elementColors : undefined; + const orbitColors = compoundColorResult(orbitPlan, token.id); const tokenBlocks = plan.blocks.filter((block) => block.colorable && block.compoundId === token.id); const mappingControls = Object.fromEntries(tokenBlocks.filter((block): block is typeof block & { groupId: string; color: string } => Boolean(block.groupId && block.color)).map((block) => [block.element, { groupId: block.groupId, color: block.color }])); const descriptions = tokenBlocks.map((block) => `${block.element} mapped to ${block.counterpartCompoundIds.join(', ')}`); return - + {index < tokens.length - 1 && } ; })} @@ -203,6 +215,11 @@ export default function ReactionStructureEquation({ equation, reversibility, ato queryKey: ['reaction-structure-compounds', compoundIdsKey], queryFn: () => getCompoundsForReaction(uniqueCompoundIds), enabled: uniqueCompoundIds.length > 0, staleTime: 5 * 60 * 1000, }); + const { data: structureMap } = useQuery({ + queryKey: ['reaction-structure-structures', compoundIdsKey], queryFn: () => getStructuresByIds(uniqueCompoundIds), + enabled: uniqueCompoundIds.length > 0, staleTime: 5 * 60 * 1000, + }); + const structures = structureMap ?? EMPTY_STRUCTURES; const displayMap = useMemo>(() => { if (!compoundMap) return EMPTY_MAP; return new Map(Array.from(compoundMap.entries(), ([id, compound]) => [id, { @@ -215,6 +232,15 @@ export default function ReactionStructureEquation({ equation, reversibility, ato ? previous : { ...previous, [compoundId]: inventory }); }, []); const inventoryCallbacks = useMemo(() => Object.fromEntries(uniqueCompoundIds.map((id) => [id, (inventory: Inventory) => saveInventory(id, inventory)])), [uniqueCompoundIds, saveInventory]); + const [graphs, setGraphs] = useState>({}); + const saveGraph = useCallback((compoundId: string, graph: HeavyAtomGraph) => { + setGraphs((previous) => { + const existing = previous[compoundId]; + return existing && existing.elements.length === graph.elements.length && existing.bonds.length === graph.bonds.length + ? previous : { ...previous, [compoundId]: graph }; + }); + }, []); + const graphCallbacks = useMemo(() => Object.fromEntries(uniqueCompoundIds.map((id) => [id, (graph: HeavyAtomGraph) => saveGraph(id, graph)])), [uniqueCompoundIds, saveGraph]); const pairs = useMemo(() => atomMappingPairs ?? [], [atomMappingPairs]); const useElementColors = pairs.length > 0; const inventoriesForPlan = useMemo(() => { @@ -227,6 +253,9 @@ export default function ReactionStructureEquation({ equation, reversibility, ato return { ...seeded, ...inventories }; }, [displayMap, inventories]); const plan = useMemo(() => buildAtomMappingColorPlan(pairs, inventoriesForPlan), [pairs, inventoriesForPlan]); + const orbitPlan = useMemo(() => buildAtomOrbitColorPlan(pairs, uniqueCompoundIds.map((compoundId) => ({ + compoundId, inchi: structures.get(compoundId)?.inchi, graph: graphs[compoundId], + }))), [pairs, structures, graphs, uniqueCompoundIds]); const [selectedGroup, setSelectedGroup] = useState(); const [hoveredGroup, setHoveredGroup] = useState(); const highlightedGroup = selectedGroup ?? hoveredGroup; @@ -237,9 +266,9 @@ export default function ReactionStructureEquation({ equation, reversibility, ato return { if (event.key === 'Escape') setSelectedGroup(undefined); }}> - setHoveredGroup(undefined)} onSelectGroup={selectGroup} /> + setHoveredGroup(undefined)} onSelectGroup={selectGroup} /> - setHoveredGroup(undefined)} onSelectGroup={selectGroup} /> + setHoveredGroup(undefined)} onSelectGroup={selectGroup} /> {error && Compound details could not be loaded.} {useElementColors && !isLoading && diff --git a/tests/unit/components/MoleculeRenderer.test.tsx b/tests/unit/components/MoleculeRenderer.test.tsx new file mode 100644 index 00000000..4c8c76be --- /dev/null +++ b/tests/unit/components/MoleculeRenderer.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import MoleculeRenderer from '@/components/ui/MoleculeRenderer'; + +const getMol = vi.fn(() => ({ + get_json: () => JSON.stringify({ molecules: [{ atoms: [{ z: 8 }, { z: 15 }], bonds: [{ atoms: [0, 1] }] }] }), + get_svg: () => "", + get_svg_with_highlights: () => "", + delete: vi.fn(), +})); + +vi.mock('@/lib/rdkit', () => ({ getRDKit: vi.fn(async () => ({ get_mol: getMol })) })); + +describe('MoleculeRenderer', () => { + it('applies explicit bond colours to locally produced SVG output', async () => { + const { container } = render(); + await waitFor(() => expect(container.innerHTML).toContain('stroke:#123456')); + }); + + it('renders a stored SVG unmodified when no SMILES is available', () => { + const fallbackSvg = ''; + const { container } = render(); + expect(container.querySelector('svg')?.getAttribute('data-stored')).toBe('true'); + expect(container.innerHTML).toContain('stroke:#000000'); + }); +}); diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx index 8d221256..01262437 100644 --- a/tests/unit/components/ReactionStructureEquation.test.tsx +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { parseAtomMappings } from '@/lib/utils/atomMapping'; import { getCompoundsForReaction, type Compound } from '@/lib/api/biochem'; +import { getStructuresByIds } from '@/lib/api/structures'; import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation'; const rendererCalls: Array> = []; @@ -15,6 +16,7 @@ const compounds = new Map([ ]); vi.mock('@/lib/api/biochem', () => ({ getCompoundsForReaction: vi.fn(async () => compounds) })); +vi.mock('@/lib/api/structures', () => ({ getStructuresByIds: vi.fn(async () => new Map()) })); vi.mock('@/components/ui/MoleculeRenderer', () => ({ default: (props: Record) => { rendererCalls.push(props); @@ -60,11 +62,58 @@ describe('ReactionStructureEquation', () => { const { container } = renderEquation({ atomMappingPairs: pairs }); await waitFor(() => expect(container.textContent).toContain('Atom mapping')); expect(container.querySelectorAll('[aria-label="Atom mapping legend"] li').length).toBeGreaterThan(0); - await waitFor(() => expect(rendererCalls.filter((call) => call.elementColors).length).toBeGreaterThan(0)); - const donor = rendererCalls.filter((call) => call.compoundId === 'cpd00012').at(-1)?.elementColors as Record; - const product = rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)?.elementColors as Record; - expect(donor.P).toBe(product.P); expect(container.textContent).toContain('individual atom pairing is not determined by the data'); + expect(rendererCalls.some((call) => call.elementColors)).toBe(false); + }); + + it('colours cpd00009 phosphorus separately from its oxygens after its graph arrives', async () => { + vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([ + ['cpd00009', { id: 'cpd00009', inchi: 'InChI=1S/H3O4P/c1-5(2,3)4', smiles: 'O=P([O-])([O-])O' }], + ])); + renderEquation({ atomMappingPairs: pairs }); + await waitFor(() => expect(rendererCalls.some((call) => call.compoundId === 'cpd00009')).toBe(true)); + const initial = rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)!; + (initial.onGraph as (graph: unknown) => void)({ elements: ['O', 'P', 'O', 'O', 'O'], bonds: [[0, 1], [1, 2], [1, 3], [1, 4]] }); + await waitFor(() => { + const colors = rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)?.atomColors as Record; + expect(colors[1]).not.toBe(colors[0]); + expect(colors[1]).not.toBe(colors[2]); + expect(colors[1]).not.toBe(colors[3]); + expect(colors[1]).not.toBe(colors[4]); + }); + }); + + it('renders without orbit colours when structures are absent', async () => { + vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map()); + const { container } = renderEquation({ atomMappingPairs: pairs }); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00009"]')).toBeTruthy()); + const result = rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)?.atomColors as Record; + expect(result ?? {}).toEqual({}); + }); + + it('passes stored SVG fallback when compound SMILES is absent', async () => { + vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([ + ['cpd00009', compound({ name: 'Phosphate', formula: 'H3O4P', charge: -1 })], + ])); + vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([ + ['cpd00009', { id: 'cpd00009', svg: '' }], + ])); + renderEquation({ equation: 'cpd00009[c] => cpd00009[c]' }); + await waitFor(() => expect(rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)?.fallbackSvg).toBe('')); + expect(rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)?.smiles).toBeUndefined(); + }); + + it('keeps repeated equivalent graph reports idempotent', async () => { + renderEquation({ atomMappingPairs: pairs }); + await waitFor(() => expect(rendererCalls.some((call) => call.compoundId === 'cpd00009')).toBe(true)); + const initial = rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)!; + const graph = { elements: ['O', 'P', 'O', 'O', 'O'], bonds: [[0, 1], [1, 2], [1, 3], [1, 4]] }; + (initial.onGraph as (graph: unknown) => void)(graph); + await waitFor(() => expect(rendererCalls.filter((call) => call.compoundId === 'cpd00009').length).toBeGreaterThan(1)); + const rendersAfterFirstGraph = rendererCalls.length; + (rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)?.onGraph as (graph: unknown) => void)(graph); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(rendererCalls.length).toBe(rendersAfterFirstGraph); }); it('keeps legacy atom colours and hides new mapping affordances without pairs', async () => { @@ -253,7 +302,7 @@ describe('ReactionStructureEquation', () => { } expect(getByText(/O: cpd00001, cpd00011 and cpd00742 — grouped/)).toBeTruthy(); expect(container.textContent).toContain('individual atom pairing is not determined by the data'); - await waitFor(() => expect(rendererCalls.some((call) => call.compoundId === 'cpd00011' && Object.keys(call.elementColors as object ?? {}).includes('C') && Object.keys(call.elementColors as object ?? {}).includes('O'))).toBe(true)); + await waitFor(() => expect(rendererCalls.some((call) => call.compoundId === 'cpd00011' && call.onGraph)).toBe(true)); const water = getByTestId('structure-cpd00001'); const allophanate = getByTestId('structure-cpd00742'); expect(Boolean(water.compareDocumentPosition(allophanate) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); From b97b8fa329a55cf2daef6a6b0cb0baed087e5386 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 14:15:30 -0500 Subject: [PATCH 25/34] feat(biochem): state atom-mapping precision honestly in the reaction equation Each participant now discloses whether its colours are exact-atom, symmetry-orbit, element-block or unresolved, with a plain-English reason, a keyboard-reachable disclosure, an upgraded legend with a precision summary, and an accurate notice when structure data cannot be loaded. Adds live full-layer InChI fixtures. --- components/ui/ReactionStructureEquation.tsx | 67 ++++++++++++------- .../ReactionStructureEquation.test.tsx | 63 +++++++++++++++++ tests/unit/utils/inchiAtomOrder.test.ts | 22 ++++++ 3 files changed, 128 insertions(+), 24 deletions(-) diff --git a/components/ui/ReactionStructureEquation.tsx b/components/ui/ReactionStructureEquation.tsx index d1509860..19bb04e2 100644 --- a/components/ui/ReactionStructureEquation.tsx +++ b/components/ui/ReactionStructureEquation.tsx @@ -15,10 +15,9 @@ import { heavyAtomCount, isParsableFormula, parseFormulaInventory } from '@/lib/ import type { AtomMappingPair } from '@/lib/utils/atomMapping'; import { buildAtomMappingColorPlan, - elementColorsForCompound, type UnmappableReason, } from '@/lib/utils/atomMappingColors'; -import { buildAtomOrbitColorPlan, compoundColorResult } from '@/lib/utils/atomOrbitColors'; +import { buildAtomOrbitColorPlan, compoundColorResult, type CompoundColorResult, type MappingPrecision } from '@/lib/utils/atomOrbitColors'; import type { HeavyAtomGraph } from '@/lib/utils/inchiAtomOrder'; import type { AtomColors } from './MoleculeRenderer'; @@ -57,6 +56,19 @@ const REASON_TEXT: Record = { 'counterpart-unresolved': 'no matching atoms found on the other side', }; const compoundLinkStyle = { color: '#00838f', textDecoration: 'none', fontWeight: 600 }; +const PRECISION_LABELS: Record = { + 'exact-atom': 'Exact atom mapping', 'symmetry-orbit': 'Symmetry-equivalent atoms', 'element-block': 'Element-level mapping', unresolved: 'No atom mapping shown', +}; +const ORBIT_REASON_TEXT: Record = { + 'no-mapping': 'This participant has no mapping data.', 'no-structure': 'Structure data is unavailable, so no atom mapping is shown.', 'merged-groups': 'Mapping groups overlap, so no atom mapping is shown.', 'partial-coverage': 'The mapping covers only part of this structure, so no atom mapping is shown.', + 'no-inchi': 'No InChI is available to establish atom correspondence.', 'multi-component': 'The InChI has multiple components, so atom correspondence cannot be established.', 'unsupported-inchi': 'This InChI form cannot establish atom correspondence.', 'formula-parse-failed': 'The InChI formula could not be interpreted for atom correspondence.', 'connection-parse-failed': 'The InChI connection data could not establish atom correspondence.', 'atom-count-mismatch': 'The structure atom count does not match the InChI.', 'element-count-mismatch': 'The structure element counts do not match the InChI.', 'bond-count-mismatch': 'The structure bonds do not match the InChI.', 'too-large': 'This structure is too large for safe atom correspondence.', 'no-isomorphism': 'The structure cannot be matched to the InChI atom graph.', 'search-exhausted': 'Atom correspondence could not be established within the safe search limit.', +}; +function precisionExplanation(result: CompoundColorResult): string { + if (result.precision === 'exact-atom') return 'Each colour identifies the exact mapped atom.'; + if (result.precision === 'symmetry-orbit') return 'This colour marks a set of symmetry-equivalent atoms; the individual atom within that set is not distinguished by the data.'; + if (result.precision === 'element-block') return 'This colour is a claim at whole-element granularity, not an individual atom correspondence.'; + return ORBIT_REASON_TEXT[result.reason ?? ''] ?? 'Atom correspondence could not be established, so no atom mapping is shown.'; +} function joinCompoundIds(ids: readonly string[]): string { if (ids.length <= 2) return ids.join(' and '); @@ -108,7 +120,6 @@ interface CompoundColumnProps { structure?: CompoundStructure; atomColors?: AtomColors; bondColors?: Record; - elementColors?: Readonly>; mappingDescription?: string; mappingControls?: Readonly>; highlightedGroup?: string; @@ -117,10 +128,11 @@ interface CompoundColumnProps { onSelectGroup: (groupId: string) => void; onInventory: (inventory: Inventory) => void; onGraph: (graph: HeavyAtomGraph) => void; - isLoading: boolean; + isLoading: boolean; precisionResult?: CompoundColorResult; precisionControlId?: string; } -function CompoundColumn({ token, data, structure, atomColors, bondColors, elementColors, mappingDescription, mappingControls, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup, onInventory, onGraph, isLoading }: CompoundColumnProps) { +function CompoundColumn({ token, data, structure, atomColors, bondColors, mappingDescription, mappingControls, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup, onInventory, onGraph, isLoading, precisionResult, precisionControlId }: CompoundColumnProps) { + const [precisionExpanded, setPrecisionExpanded] = useState(false); const smiles = data?.smiles ?? structure?.smiles; const drawStructure = Boolean(structure?.svg) || (Boolean(smiles) && (!data?.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1)); const label = data?.name || token.id; @@ -133,7 +145,6 @@ function CompoundColumn({ token, data, structure, atomColors, bondColors, elemen compoundId={token.id} atomColors={atomColors} bondColors={bondColors} - elementColors={elementColors} fallbackSvg={structure?.svg} onInventory={onInventory} onGraph={onGraph} @@ -162,6 +173,7 @@ function CompoundColumn({ token, data, structure, atomColors, bondColors, elemen {label} } {metadata} + {!isLoading && precisionResult && precisionControlId && setPrecisionExpanded(true)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setPrecisionExpanded((expanded) => !expanded); } }} onClick={() => setPrecisionExpanded((expanded) => !expanded)} sx={{ border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', color: 'text.secondary', font: 'inherit', textDecoration: 'underline', overflowWrap: 'anywhere' }}>{PRECISION_LABELS[precisionResult.precision]}{precisionExpanded && {precisionExplanation(precisionResult)}}} {!isLoading && Object.keys(mappingControls ?? {}).length > 0 && {Object.entries(mappingControls ?? {}).map(([element, control]) => { const color = control.color; @@ -176,24 +188,23 @@ function CompoundColumn({ token, data, structure, atomColors, bondColors, elemen ); } -function EquationSide({ tokens, displayMap, structures, atomMapping, useElementColors, plan, orbitPlan, callbacks, graphCallbacks, isLoading, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup }: { +function EquationSide({ tokens, displayMap, structures, atomMapping, useOrbitColors, plan, orbitPlan, callbacks, graphCallbacks, isLoading, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup, side }: { tokens: CompoundToken[]; displayMap: Map; structures: Map; - atomMapping?: ReactionAtomMapping; useElementColors: boolean; plan: ReturnType; orbitPlan: ReturnType; + atomMapping?: ReactionAtomMapping; useOrbitColors: boolean; plan: ReturnType; orbitPlan: ReturnType; callbacks: Readonly void>>; graphCallbacks: Readonly void>>; - isLoading: boolean; highlightedGroup?: string; onHighlight: (groupId: string) => void; onClearHighlight: () => void; onSelectGroup: (groupId: string) => void; + isLoading: boolean; side: string; highlightedGroup?: string; onHighlight: (groupId: string) => void; onClearHighlight: () => void; onSelectGroup: (groupId: string) => void; }) { return {tokens.map((token, index) => { - const elementColors = useElementColors ? elementColorsForCompound(plan, token.id) : undefined; - const colors = elementColors && Object.keys(elementColors).length > 0 ? elementColors : undefined; const orbitColors = compoundColorResult(orbitPlan, token.id); const tokenBlocks = plan.blocks.filter((block) => block.colorable && block.compoundId === token.id); - const mappingControls = Object.fromEntries(tokenBlocks.filter((block): block is typeof block & { groupId: string; color: string } => Boolean(block.groupId && block.color)).map((block) => [block.element, { groupId: block.groupId, color: block.color }])); + const mappingControls = Object.fromEntries(orbitPlan.groups.filter((group) => group.compoundIds.includes(token.id)).flatMap((group) => group.elements.map((element) => [element, { groupId: group.groupId, color: group.color }]))); + const precisionResult = orbitPlan.compounds[token.id]; const descriptions = tokenBlocks.map((block) => `${block.element} mapped to ${block.counterpartCompoundIds.join(', ')}`); return + atomColors={useOrbitColors ? orbitColors.atomColors : atomMapping?.[token.id]} bondColors={useOrbitColors ? orbitColors.bondColors : undefined} + mappingDescription={descriptions.join('; ') || undefined} mappingControls={mappingControls} highlightedGroup={highlightedGroup} onHighlight={onHighlight} onClearHighlight={onClearHighlight} onSelectGroup={onSelectGroup} onInventory={callbacks[token.id]} onGraph={graphCallbacks[token.id]} isLoading={isLoading} precisionResult={precisionResult} precisionControlId={`mapping-precision-${side}-${token.id}-${index}`} /> {index < tokens.length - 1 && } ; })} @@ -215,7 +226,7 @@ export default function ReactionStructureEquation({ equation, reversibility, ato queryKey: ['reaction-structure-compounds', compoundIdsKey], queryFn: () => getCompoundsForReaction(uniqueCompoundIds), enabled: uniqueCompoundIds.length > 0, staleTime: 5 * 60 * 1000, }); - const { data: structureMap } = useQuery({ + const { data: structureMap, error: structureError, isLoading: structuresLoading } = useQuery({ queryKey: ['reaction-structure-structures', compoundIdsKey], queryFn: () => getStructuresByIds(uniqueCompoundIds), enabled: uniqueCompoundIds.length > 0, staleTime: 5 * 60 * 1000, }); @@ -242,7 +253,7 @@ export default function ReactionStructureEquation({ equation, reversibility, ato }, []); const graphCallbacks = useMemo(() => Object.fromEntries(uniqueCompoundIds.map((id) => [id, (graph: HeavyAtomGraph) => saveGraph(id, graph)])), [uniqueCompoundIds, saveGraph]); const pairs = useMemo(() => atomMappingPairs ?? [], [atomMappingPairs]); - const useElementColors = pairs.length > 0; + const useOrbitColors = pairs.length > 0; const inventoriesForPlan = useMemo(() => { const seeded = Object.fromEntries(Array.from(displayMap.entries()).flatMap(([id, data]) => { const drawStructure = Boolean(data.smiles) && (!data.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1); @@ -266,20 +277,27 @@ export default function ReactionStructureEquation({ equation, reversibility, ato return { if (event.key === 'Escape') setSelectedGroup(undefined); }}> - setHoveredGroup(undefined)} onSelectGroup={selectGroup} /> + setHoveredGroup(undefined)} onSelectGroup={selectGroup} side="reactant" /> - setHoveredGroup(undefined)} onSelectGroup={selectGroup} /> + setHoveredGroup(undefined)} onSelectGroup={selectGroup} side="product" /> {error && Compound details could not be loaded.} - {useElementColors && !isLoading && + {useOrbitColors && !isLoading && !structuresLoading && orbitPlan.groups.length > 0 && {(plan.colorableCount > 0 || atomMappingConfidence || atomMappingHasSymmetryGroups) && - {plan.colorableCount > 0 && Atom mapping} + {orbitPlan.groups.length > 0 && Atom mapping} {atomMappingConfidence && } {atomMappingHasSymmetryGroups && A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined.} } - {plan.colorableCount > 0 && - {plan.legend.map((entry) => { - const description = `${entry.element}: ${entry.kind === 'one-to-one' ? entry.compoundIds.join(' and ') : `${joinCompoundIds(entry.compoundIds)} — grouped; individual atom pairing is not determined by the data`}${entry.uncoloredCompoundIds.length > 0 ? ` (not coloured: ${joinCompoundIds(entry.uncoloredCompoundIds)})` : ''}`; + {(() => { + const summary = (['exact-atom', 'symmetry-orbit', 'element-block', 'unresolved'] as MappingPrecision[]) + .filter((precision) => orbitPlan.precisionSummary[precision] > 0) + .map((precision) => `${orbitPlan.precisionSummary[precision]} ${PRECISION_LABELS[precision].toLowerCase()}`) + .join('; '); + return summary && Precision: {summary}.; + })()} + + {orbitPlan.groups.map((entry) => { + const description = `${entry.elements.join(', ')}: ${joinCompoundIds(entry.compoundIds)}${entry.hasSymmetryGroup ? ' — grouped; individual atom pairing is not determined by the data' : ''}`; return selectGroup(entry.groupId)} onMouseEnter={() => setHoveredGroup(entry.groupId)} onMouseLeave={() => setHoveredGroup(undefined)} onFocus={() => setHoveredGroup(entry.groupId)} onBlur={() => setHoveredGroup(undefined)} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', textAlign: 'left' }}> ; })} - } + {reasons.length > 0 && Some atoms could not be unambiguously mapped and therefore are not coloured: {reasons.join('; ')}.} } + {structureError && Structure data could not be loaded, so atom-level mapping precision is unavailable and any colours shown are element-level at best.} ; } diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx index 01262437..4c37c36b 100644 --- a/tests/unit/components/ReactionStructureEquation.test.tsx +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -375,3 +375,66 @@ describe('ReactionStructureEquation', () => { }); + + describe('mapping precision disclosure', () => { + const graphFor = (compoundId: string) => ({ + cpd00001: { elements: ['O'], bonds: [] }, + cpd00009: { elements: ['O', 'P', 'O', 'O', 'O'], bonds: [[0, 1], [1, 2], [1, 3], [1, 4]] }, + }[compoundId]); + + async function provideGraphs() { + await waitFor(() => expect(rendererCalls.some((call) => graphFor(call.compoundId as string))).toBe(true)); + for (const call of rendererCalls.slice()) { + const graph = graphFor(call.compoundId as string); + if (graph) (call.onGraph as (value: typeof graph) => void)(graph); + } + } + + it('renders exact, symmetry-orbit, element-level, and unresolved precision labels', async () => { + vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([ + ['cpd00001', { id: 'cpd00001', inchi: 'InChI=1S/H2O/h1H2' }], + ['cpd00009', { id: 'cpd00009', inchi: 'InChI=1S/H3O4P/c1-5(2,3)4/h(H3,1,2,3,4)/p-2' }], + ])); + const { getByRole, container } = renderEquation({ equation: 'cpd00001[c] => cpd00009[c]', atomMappingPairs: parseAtomMappings(['cpd00001:O#1=cpd00009:O#1']) }); + await provideGraphs(); + await waitFor(() => expect(getByRole('button', { name: 'Water: Exact atom mapping' })).toBeTruthy()); + expect(getByRole('button', { name: 'Phosphate: Symmetry-equivalent atoms' })).toBeTruthy(); + const precisionSummary = Array.from(container.querySelectorAll('.MuiTypography-caption')).find((node) => node.textContent?.startsWith('Precision:')); + expect(precisionSummary?.textContent).toContain('Precision:'); + expect(precisionSummary?.textContent).not.toContain('0 '); + + // Unsupported InChI can honestly colour a complete element block, while absent structure remains unresolved. + vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([['cpd00001', { id: 'cpd00001', inchi: 'InChI=1S/p+1' }]])); + const elementBlock = renderEquation({ equation: 'cpd00001[c] => cpd00009[c]', atomMappingPairs: parseAtomMappings(['cpd00001:O#1=cpd00009:O#1']) }); + await provideGraphs(); + await waitFor(() => expect(elementBlock.getByRole('button', { name: 'Water: Element-level mapping' })).toBeTruthy()); + expect(elementBlock.getByRole('button', { name: 'Phosphate: No atom mapping shown' })).toBeTruthy(); + }); + + it('associates a keyboard-activated precision control with its explanation', async () => { + vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([['cpd00001', { id: 'cpd00001', inchi: 'InChI=1S/H2O/h1H2' }]])); + const { getAllByRole } = renderEquation({ equation: 'cpd00001[c] => cpd00001[c]', atomMappingPairs: parseAtomMappings(['cpd00001:O#1=cpd00001:O#1']) }); + await provideGraphs(); + const control = await waitFor(() => getAllByRole('button', { name: 'Water: Exact atom mapping' })[0]); + fireEvent.keyDown(control, { key: 'Enter' }); + expect(control.getAttribute('aria-expanded')).toBe('true'); + expect(document.getElementById(control.getAttribute('aria-describedby') ?? '')?.textContent).toContain('exact mapped atom'); + }); + + it('keeps participants visible and reports a structures-query error while preserving honest element-level colours', async () => { + vi.mocked(getStructuresByIds).mockRejectedValueOnce(new Error('structure fetch failed')); + const { container, getByRole } = renderEquation({ atomMappingPairs: pairs }); + await waitFor(() => expect(getByRole('status').textContent).toContain('Structure data could not be loaded, so atom-level mapping precision is unavailable and any colours shown are element-level at best.')); + expect(container.querySelector('[data-testid="structure-cpd00009"]')).toBeTruthy(); + const phosphate = rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)!; + (phosphate.onGraph as (graph: unknown) => void)({ elements: ['O', 'P', 'O', 'O', 'O'], bonds: [[0, 1], [1, 2], [1, 3], [1, 4]] }); + await waitFor(() => expect(Object.keys(rendererCalls.filter((call) => call.compoundId === 'cpd00009').at(-1)?.atomColors as Record)).not.toHaveLength(0)); + }); + + it('renders no mapping legend or precision controls when no mapping pairs exist', async () => { + const { container, queryByRole } = renderEquation(); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00009"]')).toBeTruthy()); + expect(queryByRole('list', { name: 'Atom mapping legend' })).toBeNull(); + expect(queryByRole('button', { name: /mapping$/ })).toBeNull(); + }); + }); diff --git a/tests/unit/utils/inchiAtomOrder.test.ts b/tests/unit/utils/inchiAtomOrder.test.ts index 831dcef0..f514e066 100644 --- a/tests/unit/utils/inchiAtomOrder.test.ts +++ b/tests/unit/utils/inchiAtomOrder.test.ts @@ -100,3 +100,25 @@ describe('buildInchiAtomOrbits', () => { .toEqual(buildInchiAtomOrbits(phosphateInchi, phosphateGraph)); }); }); + +describe('live InChI forms', () => { + it('preserves phosphate orbit results with its full charge layer', () => { + const result = buildInchiAtomOrbits('InChI=1S/H3O4P/c1-5(2,3)4/h(H3,1,2,3,4)/p-2', phosphateGraph); + expect(result).toMatchObject({ ok: true, solutionCount: 24, exact: false }); + if (result.ok) expect(result.orbits[4]).toEqual([1]); + }); + + it('accepts ammonium and rejects the hydrogen ion InChI safely', () => { + expect(buildInchiAtomOrbits('InChI=1S/H3N/h1H3/p+1', { elements: ['N'], bonds: [] })) + .toEqual(expect.objectContaining({ ok: true })); + expect(buildInchiAtomOrbits('InChI=1S/p+1', { elements: ['H'], bonds: [] })) + .toEqual({ ok: false, reason: 'unsupported-inchi' }); + }); + + it('handles the live multi-layer allophanate InChI', () => { + expect(buildInchiAtomOrbits('InChI=1S/C2H4N2O3/c3-1(5)4-2(6)7/h(H,6,7)(H3,3,4,5)/p-1', { + elements: ['N', 'C', 'O', 'N', 'C', 'O', 'O'], + bonds: [[0, 1], [1, 2], [1, 3], [3, 4], [4, 5], [4, 6]], + })).toEqual(expect.objectContaining({ ok: true })); + }); +}); From cf4b5e007e17a21dc36756256410f236d894d946 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 14:43:05 -0500 Subject: [PATCH 26/34] fix(biochem): reject out-of-range mapping refs and unsafe structure ids Element-block degradation compared only the size of the mapped index set against the element count, so an out-of-range reference such as O#3 on a two-oxygen compound matched by cardinality and coloured both atoms as fully covered. Require the contiguous set 1..count instead. The structures Solr client also interpolated compound ids into the query unescaped; filter Solr-unsafe ids before building the request. --- lib/api/structures.ts | 2 +- lib/utils/atomOrbitColors.ts | 2 +- tests/unit/api/structures.test.ts | 12 ++++++++++++ tests/unit/utils/atomOrbitColors.test.ts | 19 +++++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/api/structures.ts b/lib/api/structures.ts index 12fc5164..dffc3131 100644 --- a/lib/api/structures.ts +++ b/lib/api/structures.ts @@ -54,7 +54,7 @@ export async function getStructuresByIds(ids: string[]): Promise typeof id === 'string') .map((id) => id.trim()) - .filter(Boolean), + .filter((id) => /^[A-Za-z0-9_.-]+$/.test(id)), )); const structures = new Map(); if (uniqueIds.length === 0) return structures; diff --git a/lib/utils/atomOrbitColors.ts b/lib/utils/atomOrbitColors.ts index 051bd3c4..f63f74b3 100644 --- a/lib/utils/atomOrbitColors.ts +++ b/lib/utils/atomOrbitColors.ts @@ -201,7 +201,7 @@ function degrade(compoundId: string, graph: HeavyAtomGraph, groups: InternalGrou } const indices = new Set(matching.map(({ ref }) => ref.index)); const count = graph.elements.filter((item) => item === element).length; - if (indices.size !== count) { + if (indices.size !== count || Math.min(...indices) !== 1 || Math.max(...indices) !== count) { denialReason ??= 'partial-coverage'; continue; } diff --git a/tests/unit/api/structures.test.ts b/tests/unit/api/structures.test.ts index 373710f0..5cb6d7ae 100644 --- a/tests/unit/api/structures.test.ts +++ b/tests/unit/api/structures.test.ts @@ -43,6 +43,18 @@ describe('getStructuresByIds', () => { expect(url).toContain('fl=id,smiles,inchi,inchikey,svg'); }); + it('drops unsafe ids while returning structures for well-formed ids', async () => { + const api = await loadStructuresApi(); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(solrResponse({ + response: { docs: [{ id: 'cpd00001', inchi: 'InChI=1S/H2O/h1H2' }] }, + })); + + const structures = await api.getStructuresByIds(['cpd00001', 'cpd00001 OR *:*']); + + expect(String(fetchMock.mock.calls[0]?.[0])).not.toContain('cpd00001 OR *:*'); + expect(structures.get('cpd00001')).toEqual({ id: 'cpd00001', inchi: 'InChI=1S/H2O/h1H2' }); + }); + it.each([ ['HTTP 404', () => Promise.resolve(solrResponse({}, 404))], ['a rejected fetch', () => Promise.reject(new Error('network failure'))], diff --git a/tests/unit/utils/atomOrbitColors.test.ts b/tests/unit/utils/atomOrbitColors.test.ts index 44edc7f8..1b847544 100644 --- a/tests/unit/utils/atomOrbitColors.test.ts +++ b/tests/unit/utils/atomOrbitColors.test.ts @@ -52,6 +52,25 @@ describe('buildAtomOrbitColorPlan', () => { expect(partial.compounds.cpd00001).toMatchObject({ precision: 'unresolved', reason: 'partial-coverage' }); }); + it('requires contiguous element indices before claiming a degraded element block', () => { + const invalid = buildAtomOrbitColorPlan( + parseAtomMappings(['cpd00001:(O#1;O#3)=cpd00002:(O#1;O#2)']), + [{ compoundId: 'cpd00001', graph: { elements: ['O', 'O'], bonds: [] } }], + ); + expect(invalid.compounds.cpd00001).toMatchObject({ + precision: 'unresolved', reason: 'partial-coverage', coloredAtomCount: 0, + }); + + const valid = buildAtomOrbitColorPlan( + parseAtomMappings(['cpd00001:(O#1;O#2)=cpd00002:(O#1;O#2)']), + [{ compoundId: 'cpd00001', graph: { elements: ['O', 'O'], bonds: [] } }], + ); + expect(valid.compounds.cpd00001).toMatchObject({ + precision: 'element-block', coloredAtomCount: 2, + atomColors: { 0: MAPPING_PALETTE[0], 1: MAPPING_PALETTE[0] }, + }); + }); + it('drops invalid references, tolerates invalid inputs, and is deterministic', () => { const malformed = [{ ...pair('cpd00001', 'cpd00002'), leftAtoms: [{ compoundId: 'cpd00001', element: 'O', index: 99 }, { compoundId: 'cpd00001', element: 'O', index: 0 }, { compoundId: 'cpd00001', element: 'X', index: Number.NaN }], rightAtoms: [{ compoundId: 'cpd00002', element: 'O', index: 1 }] }]; const input = [{ compoundId: 'cpd00001', inchi: 'InChI=1S/O4/c1-2-3-4', graph: { elements: ['O', 'O', 'O', 'O'], bonds: [[0, 1], [1, 2], [2, 3]] as Array<[number, number]> } }]; From 3eced37c136146f5ca0d987fc420e4115f3af5ef Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 14:45:34 -0500 Subject: [PATCH 27/34] docs(biochem): document InChI-derived atom mapping and release 3.3.0 Rewrite the atom-mapping contract document around the structures Solr core, the Hill/connection-layer orbit method and the four-level precision ladder, and record the remaining server-side gap. Add the structures collection variables to .env.example and bump to 3.3.0. --- .env.example | 13 ++- CHANGELOG.md | 17 ++-- VERSION.md | 2 +- docs/ATOM_MAPPING.md | 237 +++++++++++++++---------------------------- docs/README.md | 2 +- package.json | 2 +- 6 files changed, 104 insertions(+), 169 deletions(-) diff --git a/.env.example b/.env.example index 1a88cbd6..ea31aace 100644 --- a/.env.example +++ b/.env.example @@ -121,20 +121,23 @@ NEXT_PUBLIC_SOLR_BASE_URL_PRODUCTION=https://modelseed.org/solr/ # ============================================================================= # SOLR COLLECTION / CORE NAMES # ============================================================================= -# The Solr core names for the reactions and compounds collections. +# The Solr core names for the reactions, compounds, and structures collections. # These must match the names configured in your Solr instance. # # Override: Required in manual mode, otherwise optional (mode default used) -# Mode default: staging=reactions_staging / compounds_staging -# production=reactions / compounds -# Fallback: staging: "reactions_staging" / "compounds_staging" -# production: "reactions" / "compounds" +# Mode default: staging=reactions_staging / compounds_staging / structures_staging +# production=reactions / compounds / structures +# Fallback: staging: "reactions_staging" / "compounds_staging" / "structures_staging" +# production: "reactions" / "compounds" / "structures" NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION= NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION= +NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION= NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION_STAGING=reactions_staging NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_STAGING=compounds_staging +NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_STAGING=structures_staging NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION_PRODUCTION=reactions NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_PRODUCTION=compounds +NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_PRODUCTION=structures # ============================================================================= # SOLR NESTED SCHEMA OVERRIDE diff --git a/CHANGELOG.md b/CHANGELOG.md index 57344632..1ff54352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - TBD -### Added -- Reaction structure equations now let researchers hover, focus, or select atom-mapping groups to emphasise every participating compound across the canvas - -### Fixed -- Formula-derived atom inventories no longer temporarily colour drawn structures before RDKit has verified their element coverage - ### Known Issues - RAST MS FBA not working - PATRIC-only model submission @@ -23,6 +17,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [3.3.0] - 2026-08-21 + +### Added +- Reaction structure equations now resolve atom-mapping colours from raw InChI canonical order through the structures Solr core, rather than treating canonical `#N` references as SMILES or renderer positions +- Each mapped participant and the legend now disclose whether a highlight is an exact atom, a symmetry-equivalent orbit, a whole-element block, or unresolved, so researchers can see precisely what the mapping supports + +### Fixed +- Atom-mapping highlights no longer assign chemically false colours when InChI canonical order diverges from stored-SMILES order + +--- + ## [3.2.0] - 2026-08-20 ### Added diff --git a/VERSION.md b/VERSION.md index 944880fa..15a27998 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -3.2.0 +3.3.0 diff --git a/docs/ATOM_MAPPING.md b/docs/ATOM_MAPPING.md index 17ad2487..0f4f44c3 100644 --- a/docs/ATOM_MAPPING.md +++ b/docs/ATOM_MAPPING.md @@ -2,29 +2,21 @@ > **🤖 AI Agent Quick-Start** > The `#N` numbers in `atom_mapping_data` are **not** RDKit atom indices and **not** SMILES -> atom positions. Never pass one into an atom-index or highlight-index array. Colour may be -> asserted at **(compound, element) block** granularity only. If you think you have found a -> way to colour individual atoms, read "Why per-atom colouring is not possible" first. +> atom positions. They are 1-based positions within an element in InChI canonical (Hill) +> order. Resolve them through the InChI structure method described below; never use them as +> renderer indices directly. -This document describes the atom-mapping data the ModelSEED biochemistry Solr index -publishes, exactly what the UI can and cannot derive from it, and the precise server-side -contract that would be required to render true per-atom and per-bond mappings. +This document describes the atom-mapping data published by the ModelSEED biochemistry +Solr index, the precise claims the UI can now make, and the remaining server-side contract +needed to identify a unique atom in every case. --- ## 📦 The data as published -Source core: `reactions_staging` on the Poplar Solr host. Reaction documents that carry a -mapping have: - -| Field | Type | Meaning | -| :--- | :--- | :--- | -| `has_atom_mapping` | boolean | Whether a mapping exists (32,877 reactions at time of writing). | -| `atom_mapping_data` | multi-valued string | The mapping itself, one relationship per value. | -| `atom_mapping_confidence` | string | Exactly two observed values: `clean` (25,058) and `salvaged` (7,819). | -| `atom_mapping_has_symmetry_groups` | boolean | Whether any relationship involves a symmetry-equivalent set. | - -### Grammar of an `atom_mapping_data` entry +Reaction mappings come from `reactions_staging` on the Poplar Solr host. A mapped reaction +has `has_atom_mapping`, multi-valued `atom_mapping_data`, `atom_mapping_confidence`, and +`atom_mapping_has_symmetry_groups` fields. An entry has this grammar: ``` ::= "=" @@ -33,155 +25,91 @@ mapping have: ::= "#" ``` -`rxn00002` (urea-carboxylate hydrolase), verbatim from the index: - -``` -cpd00001:O#1=cpd00011:(O#1;O#2) -cpd00742:(O#2;O#3)=cpd00011:(O#1;O#2) -cpd00742:C#1=cpd00011:C#1 -cpd00742:C#2=cpd00011:C#1 -cpd00742:N#1=cpd00013:N#1 -cpd00742:N#2=cpd00013:N#1 -cpd00742:O#1=cpd00011:(O#1;O#2) -``` - -`rxn00001` (diphosphate phosphohydrolase): - -``` -cpd00001:O#1=cpd00009:(O#1;O#2;O#3;O#4) -cpd00012:(O#1;O#2;O#3;O#4;O#5;O#6)=cpd00009:(O#1;O#2;O#3;O#4) -cpd00012:(P#1;P#2)=cpd00009:P#1 -cpd00012:O#7=cpd00009:(O#1;O#2;O#3;O#4) -``` - -Observed properties of the grammar: - -- The relation is **symmetric** and **element-preserving**: the element symbol is always the - same on both sides of `=`. -- A parenthesised set means "these atoms are interchangeable for the purpose of this - relationship" — a symmetry group, not an ordered pairing. -- Relationships are **many-to-many across compounds**. Over the live corpus, only **75.5 %** - of `(reaction, compound, element)` blocks map to exactly one counterpart compound; the - remaining **24.5 %** map to several (e.g. `cpd00025:O` → `[cpd00001, cpd00007]`). - -### What `#N` actually indexes - -`#N` is a **1-based index, per element, per compound, in InChI canonical atom order**. -It is not a position in the compound's SMILES string, not an RDKit atom index, and not an -RDKit atom-map number. See `lib/utils/atomMapping.ts:15-21`. +For example, `cpd00009:P#1` and `cpd00012:(O#1;O#2)` refer to one phosphorus and an +interchangeable oxygen set. Relations are symmetric and element-preserving; parenthesised +sets are symmetry groups, not ordered pairings. Relationships may also be many-to-many +across compounds. + +`#N` is a **1-based index, per element, per compound, in InChI canonical (Hill) order**. +It is not a SMILES position, RDKit atom index, or RDKit atom-map number. This distinction is +material: for `cpd00009` (H3O4P), RDKit built from stored SMILES orders heavy atoms as +`[O,P,O,O,O]`, while the InChI Hill order is `[O,O,O,O,P]`. Treating `P#1` as a renderer +index would colour a chemically false atom. + +The `structures_staging` core at `http://poplar:8983/solr` is the only published raw-InChI +source. Its 45,708 documents expose `id`, `inchi`, `inchikey`, `smiles`, and `svg`; the +compound core exposes only SMILES and InChIKey. Coverage is incomplete: this is about 25% of +180,050 compounds, 15,330 structure documents have no `inchi`, and 8,765 have no `smiles`. +The core is not currently reachable through the public `https:///solr` proxy: both +staging.modelseed.org and modelseed.org return 404. That deployment gap blocks this method +outside a direct Poplar-backed environment. + +The stored `svg` is a plain, unhighlighted RDKit depiction. It carries no mapping +information and is used only as a fallback picture when local RDKit cannot render; highlights +are always drawn locally. --- -## 🚫 Why per-atom colouring is not possible today +## ✅ What the UI can resolve -To paint atom `O#2` of `cpd00742` you must answer: *which atom of the rendered molecule is -the second oxygen in InChI canonical order?* Three independent facts make that unanswerable -in the browser: +The UI uses the structures core to make a conservative correspondence between canonical atom +references and the RDKit heavy-atom graph built from stored SMILES: -1. **The index carries no geometry.** `atom_mapping_data` has no coordinates, no bonds, and - no atom-order key. It is a pure relationship between abstract atom identities. -2. **The compound documents carry no canonical-order source.** `compounds_staging` publishes - `id`, `name`, `abbreviation`, `formula`, `charge`, `mass`, `inchikey`, `smiles`, - `aliases`, `atom_count_*` and `has_structure`. There is **no InChI string, no molfile, no - structure block, and no atom-order field** in any core. An InChIKey is a hash and cannot - be inverted. -3. **The client-side toolkit cannot recover the order.** `@rdkit/rdkit` 2025.3.4 exposes - `JSMol.get_inchi()` with no parameters; `get_aux_info()` and `get_canonical_ranking()` do - not exist, and `RDKit_minimal.js` contains zero occurrences of AuxInfo. The InChI - `/AuxInfo` layer — the only thing that maps InChI canonical numbers back to input atom - order — is therefore unreachable. +1. It parses the InChI formula layer in Hill order to map each canonical number to an element, + then parses the `/c` connection layer into a canonical-numbered edge list. +2. It enumerates **all** element-preserving isomorphisms from that canonical graph onto the + RDKit graph. If an enumeration cap is reached, the result is discarded rather than returned. +3. For canonical atom `i`, `orbit(i)` is the union of every RDKit target of `i` over all + isomorphisms. It therefore contains the true rendered atom, but can contain + symmetry-equivalent atoms. +4. For rendered atom `a`, `candidates(a) = { i : a ∈ orbit(i) }`. A mapping group colours + `a` only when `candidates(a)` is non-empty and is a subset of that group's canonical + indices. A bond is coloured only when both endpoints are coloured for the same group. -Any client-side guess (matching by element in SMILES order, or by RDKit's own canonical -ranking) produces a mapping that *looks* authoritative and is *silently wrong*. The UI -refuses to do this. This is enforced as a product invariant: +This is deliberately not a guess from SMILES order. In the live sample, 69 of 76 compounds +resolve; only 8 resolve uniquely, with mean orbit size 2.40 and maximum 6. A +symmetry-equivalent result is therefore the common, honest outcome. -> **No atom is rendered with a colour that asserts an atom-level correspondence the data -> cannot justify.** +### Precision disclosed for every participant ---- - -## ✅ What the UI does instead - -`lib/utils/atomMappingColors.ts` implements a **(compound, element) block model**: - -1. Every `atom_mapping_data` entry is parsed into a pair of `(compoundId, element, indices)` - blocks (`lib/utils/atomMapping.ts`). -2. Blocks of the same element are joined into **connected components** across compounds via - their counterpart relationships (BFS over the adjacency graph). A component that spans at - least two blocks in at least two compounds is colourable. -3. A component is labelled **one-to-one** when it has exactly two blocks and each has a - single counterpart compound; otherwise it is labelled **merged** — which is the honest - description of the 24.5 % many-to-one case, and of every symmetry group. -4. A block is coloured only when the number of mapped indices equals the compound's actual - structural atom count for that element (`lib/utils/atomMappingColors.ts:137`). If the - mapping covers only part of an element block, the whole element is *not* coloured and the - member is named in the legend as uncoloured. This is the safety gate that keeps the - colour a claim about the whole element block rather than about particular atoms. -5. Colour is then applied by **element symbol** through `MoleculeRenderer`'s `elementColors` - prop, which paints every atom of that element in that compound — never a chosen index. - -`rxn00002` therefore renders three groups — **C**, **N**, and a **merged O** spanning -`cpd00001`, `cpd00011` and `cpd00742` — with the legend stating that individual atom pairing -is not determined by the data. - ---- - -## 📜 Server-side contract required for true per-atom mapping - -Per-atom and per-bond colouring becomes possible, with no change to the safety invariant, -if the biochemistry index publishes **any one** of the following. They are listed in -ascending order of server effort; the first is sufficient. - -### Option A — publish the structure the indices refer to (preferred) +The reaction equation and legend disclose one of four levels per participant: -Add to each compound document in `compounds_staging`: - -| Field | Type | Requirement | -| :--- | :--- | :--- | -| `inchi` | string | The full standard InChI, **including the `/AuxInfo=` layer**. | -| `molfile` | string | The exact molblock the InChI was generated from. | - -The AuxInfo `/N:` component gives the permutation from InChI canonical numbering to molfile -atom order. The client then renders the molfile (not the SMILES), and `El#N` resolves to a -concrete molfile atom index. **Both fields must come from the same generation run** — an -AuxInfo string paired with a different molblock is worse than no data. - -### Option B — publish the resolved index directly - -Add to each compound document: +| Precision | Meaning | +| :--- | :--- | +| `exact-atom` | The safe candidate rule identifies the drawn atom(s) without ambiguity. | +| `symmetry-orbit` | The highlight is restricted to a symmetry-equivalent orbit that contains the true atom, but does not identify one unique atom. | +| `element-block` | The UI can make only a whole-element claim: every reference for that element is in one group and mapped indices are exactly the contiguous set `1..count`. | +| `unresolved` | No safe highlight is made; the UI reports a machine-readable reason. | -| Field | Type | Requirement | -| :--- | :--- | :--- | -| `atom_order_smiles` | string | The exact SMILES the indices are aligned to. | -| `atom_mapping_index_map` | string | For each element, the mapping from `#N` to the 0-based atom position in `atom_order_smiles`, e.g. `O:1>0,2>3,3>5;C:1>1`. | +No level claims an ordered atom-to-atom pairing that `atom_mapping_data` does not publish. -The client then renders `atom_order_smiles` and indexes it directly. This is the smallest -payload, but it hard-couples the index to one SMILES serialisation, so the field must be -regenerated whenever the structure is. +--- -### Option C — publish atom-mapped reaction SMILES +## 📜 Remaining gap: server-side contract for unique identity -Add to each reaction document: +The orbit method proves containment, not unique identity: two or more graph-symmetric atoms +can remain indistinguishable. A server-published canonical atom-index map (**Option B**) or +mapped reaction SMILES (**Option C**) is required to collapse that ambiguity. -| Field | Type | Requirement | -| :--- | :--- | :--- | -| `reaction_smiles_mapped` | string | A reaction SMILES with RDKit atom-map numbers, e.g. `[OH2:1].[C:2](=[O:3])…>>…`. | +### Option B — publish a canonical-to-renderer index map -This is the industry-standard form and requires no per-compound alignment at all: RDKit -parses the atom-map numbers natively. It also encodes bond fate, which is the only one of -the three options that makes **bond**-level colouring exact rather than inferred. +For each structure, backend owners must publish the exact structure string the client is to +render and an atom-index map whose semantics are unambiguous: for every InChI canonical +`Element#N`, it must name the corresponding **0-based atom index in that exact structure +string**. The structure string and map must be generated together and remain paired whenever +the structure changes. A field such as `atom_mapping_index_map` is sufficient only after those +field semantics, indexing base, canonical-order source, and renderer structure are confirmed. -### Additionally useful, independent of the option chosen +### Option C — publish mapped reaction SMILES -- `stoichiometry` on the reaction document. It is **absent** today, so coefficients must be - re-parsed from the equation string. -- A per-relationship confidence, rather than one `atom_mapping_confidence` for the whole - reaction, so a `salvaged` reaction does not have to be presented as uniformly uncertain. -- An explicit symmetry-group identifier, so equivalent atoms can be shown as a named - equivalence class instead of being inferred from the parentheses. +Alternatively, each reaction can publish `reaction_smiles_mapped`: reaction SMILES whose +atoms have stable RDKit atom-map numbers and whose reactant/product map numbers identify the +same atom. Backend owners must confirm that those numbers are authoritative for the displayed +structures and preserve the mapping semantics. This form also publishes bond fate directly. -Until one of A, B or C lands, the block model above is the most specific claim the data -supports, and the UI will not exceed it. +Until either contract is available, the four-level disclosure above is the most precise claim +the UI makes. The structures-core method safely narrows highlights; it does not convert a +symmetry class into a unique identity. --- @@ -189,10 +117,9 @@ supports, and the UI will not exceed it. | Concern | File | | :--- | :--- | -| Parsing `atom_mapping_data`, and the `#N` warning | `lib/utils/atomMapping.ts` | -| Connected-component block model, legend, safety gate | `lib/utils/atomMappingColors.ts` | -| Element→colour application onto RDKit SVG output | `lib/utils/moleculeHighlights.ts` | -| Molecule rendering and the `elementColors` prop | `components/ui/MoleculeRenderer.tsx` | -| The reaction equation canvas and its legend | `components/ui/ReactionStructureEquation.tsx` | -| Formula → element inventory | `lib/utils/chemicalFormula.ts` | -| Solr field selection for reactions and compounds | `lib/api/biochem.ts` | +| Structures-core client | `lib/api/structures.ts` | +| Structures collection configuration | `lib/api/config.ts` | +| InChI Hill-order and connection parsing | `lib/utils/inchiAtomOrder.ts` | +| Isomorphism orbits and safe mapping colours | `lib/utils/atomOrbitColors.ts` | +| Local highlights, bonds, graph disclosure, and SVG fallback | `components/ui/MoleculeRenderer.tsx` | +| Structures query, precision disclosure, legend, and error notice | `components/ui/ReactionStructureEquation.tsx` | diff --git a/docs/README.md b/docs/README.md index 5a368e45..f772a3f0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ This directory contains the technical documentation for the ModelSEED-UI applica | **User Sessions** | [AUTHENTICATION.md](./AUTHENTICATION.md) | RAST/PATRIC login flow, `useAuth` Zustand store, and Token management for API requests. | | **External Data** | [WORKSPACE.md](./WORKSPACE.md) | Handling PATRIC Workspace JSON-RPC objects and the `modelseed-api` proxy endpoints. | | **Scientific Data** | [BIOCHEMISTRY.md](./BIOCHEMISTRY.md) | Solr-indexed reactions/compounds lookup and chemical formula/stoichiometry UX rendering rules. | -| **Atom Mapping** | [ATOM_MAPPING.md](./ATOM_MAPPING.md) | The `atom_mapping_data` grammar, why per-atom colouring is impossible client-side, and the server-side contract that would enable it. | +| **Atom Mapping** | [ATOM_MAPPING.md](./ATOM_MAPPING.md) | The `atom_mapping_data` grammar, InChI-orbit colour precision ladder, structures-core coverage, and the remaining server contract for unique atom identity. | | **Legacy Codebase** | [LEGACY_TRANSITION.md](./LEGACY_TRANSITION.md) | Transitioning from the AngularJS source code to modern React patterns. | | **Testing Platform** | [TESTING.md](./TESTING.md) | Vitest unit tests, Playwright E2E tests, and CI/CD pipeline. | | **Deploying** | [DEPLOYMENT.md](./DEPLOYMENT.md) | Environment variables, deployment modes, URL resolution, and configuration guide. | diff --git a/package.json b/package.json index 184c5776..2591c184 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modelseed-ui", - "version": "3.2.0", + "version": "3.3.0", "private": true, "scripts": { "predev": "node scripts/sync-version-from-env.mjs", From 3843d2ca0b08755ba5b9a3745b7accf975ab48c9 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 17:53:43 -0500 Subject: [PATCH 28/34] feat(api): make Solr endpoints configurable per corpus Reaction, compound and structure lookups each resolve their own Solr base URL from NEXT_PUBLIC_SOLR__BASE_URL, its mode-specific _STAGING/_PRODUCTION default, and finally the shared legacy base, so a deployment can point structures at a different Solr than the public proxy without moving the other corpora. Adds the missing NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION* keys to the statically referenced PUBLIC_ENV map; without them Next never inlined the values and structure-core overrides were silently inert in the browser. buildSolrUrl is narrowed to the reactions/compounds union so no corpus can fall through to the shared base unnoticed. Adds an optional server-side rewrite from /solr/:path* to $SOLR_PROXY_UPSTREAM/:path*, enabled only when that server-only variable is set, so a checkout can serve Solr same-origin from a host that sends no CORS headers. Release 3.4.0. --- .env.example | 17 ++++++++++++ CHANGELOG.md | 11 ++++++++ VERSION.md | 2 +- lib/api/biochem.ts | 25 ++++++----------- lib/api/config.ts | 52 +++++++++++++++++++++++++++++++++-- lib/api/solrSchema.ts | 4 +-- lib/api/structures.ts | 4 +-- next.config.ts | 7 +++++ package.json | 2 +- tests/unit/api/config.test.ts | 52 +++++++++++++++++++++++++++++++++++ 10 files changed, 152 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index ea31aace..00f052b7 100644 --- a/.env.example +++ b/.env.example @@ -118,6 +118,23 @@ NEXT_PUBLIC_SOLR_BASE_URL= NEXT_PUBLIC_SOLR_BASE_URL_STAGING=https://staging.modelseed.org/solr/ NEXT_PUBLIC_SOLR_BASE_URL_PRODUCTION=https://modelseed.org/solr/ +# Per-corpus base precedence: explicit override → mode default → shared +# NEXT_PUBLIC_SOLR_BASE_URL resolution. Leave empty to retain shared-base behavior. +# A relative value such as /solr/ is served through the SOLR_PROXY_UPSTREAM rewrite. +NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL= +NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_STAGING= +NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_PRODUCTION= +NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL= +NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_STAGING= +NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_PRODUCTION= +NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL= +NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_STAGING= +NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_PRODUCTION= + +# Server-side only; for dev/internal use. http://poplar:8983/solr is an internal +# host and must never be used as a public production value. +SOLR_PROXY_UPSTREAM= + # ============================================================================= # SOLR COLLECTION / CORE NAMES # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff54352..ac4e3518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [3.4.0] - 2026-08-21 + +### Added +- Solr reaction, compound and structure lookups can now each use their own endpoint and core through separate environment variables, while retaining the shared Solr base when no per-corpus value is set +- An optional server-side proxy lets a deployment or local checkout serve Solr from its own origin + +### Fixed +- Structure-core environment overrides now reach browser lookups instead of silently falling back to the shared endpoint + +--- + ## [3.3.0] - 2026-08-21 ### Added diff --git a/VERSION.md b/VERSION.md index 15a27998..18091983 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -3.3.0 +3.4.0 diff --git a/lib/api/biochem.ts b/lib/api/biochem.ts index ebb12c9e..5b745e88 100644 --- a/lib/api/biochem.ts +++ b/lib/api/biochem.ts @@ -12,12 +12,10 @@ import { CPD_IMG_BASE, MODELSEED_API_URL, - SOLR_BASE, - SOLR_BASE_LEGACY, - SOLR_COMPOUNDS_COLLECTION, - SOLR_REACTIONS_COLLECTION, + solrCorpusEndpoint, } from './config'; import { hasNestedSchema, parentDocTypeFilter } from './solrSchema'; +import type { BiochemCollection } from './solrSchema'; /* ─── Types ──────────────────────────────────────────────────── */ @@ -387,14 +385,9 @@ function buildQuickSearchClause( /** * Builds a Solr query URL from options, mirroring legacy `get_solr`. */ -function buildSolrUrl(collection: string, opts: SolrQueryOpts = {}): string { - const collectionName = - collection === 'reactions' - ? SOLR_REACTIONS_COLLECTION - : collection === 'compounds' - ? SOLR_COMPOUNDS_COLLECTION - : collection; - let url = `${SOLR_BASE}${collectionName}/select?wt=json`; +function buildSolrUrl(collection: BiochemCollection, opts: SolrQueryOpts = {}): string { + const endpoint = solrCorpusEndpoint(collection); + let url = `${endpoint}/select?wt=json`; const { query, @@ -1077,7 +1070,7 @@ export async function getCompoundsFromModelseedApi( */ export async function getReactionById(id: string): Promise { // Keep detail lookups on legacy Solr until modelseed-api exposes an ID endpoint. - let url = `${SOLR_BASE_LEGACY}${SOLR_REACTIONS_COLLECTION}/select?wt=json&q=id:${id}`; + let url = `${solrCorpusEndpoint('reactions')}/select?wt=json&q=id:${id}`; const nested = await hasNestedSchema('reactions'); if (nested) { url += `&fq=${encodeURIComponent(parentDocTypeFilter('reactions'))}&fl=${encodeURIComponent('*,[child childFilter=doc_type:thermodynamics]')}`; @@ -1102,7 +1095,7 @@ export async function getReactionById(id: string): Promise { */ export async function getCompoundById(id: string): Promise { // Keep detail lookups on legacy Solr until modelseed-api exposes an ID endpoint. - let url = `${SOLR_BASE_LEGACY}${SOLR_COMPOUNDS_COLLECTION}/select?wt=json&q=id:${id}`; + let url = `${solrCorpusEndpoint('compounds')}/select?wt=json&q=id:${id}`; const nested = await hasNestedSchema('compounds'); if (nested) { url += `&fq=${encodeURIComponent(parentDocTypeFilter('compounds'))}&fl=${encodeURIComponent('*,[child childFilter=doc_type:thermodynamics]')}`; @@ -1151,7 +1144,7 @@ function getCompoundsByIdsWithFields(ids: string[], fields: string[]): Promise `id:${id}`).join(' OR '); const fl = fields.join(','); // Batch ID fetch is currently Solr-backed for both modes. - const url = `${SOLR_BASE_LEGACY}${SOLR_COMPOUNDS_COLLECTION}/select?wt=json&q=(${idQuery})&rows=${uniqueIds.length}&fl=${fl}`; + const url = `${solrCorpusEndpoint('compounds')}/select?wt=json&q=(${idQuery})&rows=${uniqueIds.length}&fl=${fl}`; return fetchSolr(url).then((res) => { const map = new Map(); @@ -1187,7 +1180,7 @@ export async function findReactionsForCompound( const sort = opts.sort; // Reverse compound lookup remains Solr-backed for now. - let url = `${SOLR_BASE_LEGACY}${SOLR_REACTIONS_COLLECTION}/select?wt=json&q=equation:*${cpdId}*&fl=*`; + let url = `${solrCorpusEndpoint('reactions')}/select?wt=json&q=equation:*${cpdId}*&fl=*`; if (limit) url += `&rows=${limit}`; if (offset) url += `&start=${offset}`; if (sort) { diff --git a/lib/api/config.ts b/lib/api/config.ts index a4348704..0f0a9dbd 100644 --- a/lib/api/config.ts +++ b/lib/api/config.ts @@ -26,12 +26,24 @@ const PUBLIC_ENV = { NEXT_PUBLIC_SOLR_BASE_URL: process.env.NEXT_PUBLIC_SOLR_BASE_URL, NEXT_PUBLIC_SOLR_BASE_URL_STAGING: process.env.NEXT_PUBLIC_SOLR_BASE_URL_STAGING, NEXT_PUBLIC_SOLR_BASE_URL_PRODUCTION: process.env.NEXT_PUBLIC_SOLR_BASE_URL_PRODUCTION, + NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL: process.env.NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL, + NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_STAGING: process.env.NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_STAGING, + NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_PRODUCTION: process.env.NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_PRODUCTION, NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION: process.env.NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION, NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION_STAGING: process.env.NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION_STAGING, NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION_PRODUCTION: process.env.NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION_PRODUCTION, + NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL: process.env.NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL, + NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_STAGING: process.env.NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_STAGING, + NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_PRODUCTION: process.env.NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_PRODUCTION, NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION: process.env.NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION, NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_STAGING: process.env.NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_STAGING, NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_PRODUCTION: process.env.NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_PRODUCTION, + NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL: process.env.NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL, + NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_STAGING: process.env.NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_STAGING, + NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_PRODUCTION: process.env.NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_PRODUCTION, + NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION: process.env.NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION, + NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_STAGING: process.env.NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_STAGING, + NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_PRODUCTION: process.env.NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_PRODUCTION, NEXT_PUBLIC_USE_MODELSEED_API: process.env.NEXT_PUBLIC_USE_MODELSEED_API, NEXT_PUBLIC_USE_NEW_PROXY: process.env.NEXT_PUBLIC_USE_NEW_PROXY, NEXT_PUBLIC_PROBMODELSEED_URL: process.env.NEXT_PUBLIC_PROBMODELSEED_URL, @@ -247,6 +259,8 @@ export const SOLR_BASE_LEGACY = ensureTrailingSlash( */ export const SOLR_BASE = SOLR_BASE_LEGACY; +export type SolrCorpus = 'reactions' | 'compounds' | 'structures'; + function resolveSolrCollection(params: { overrideVar: string; stagingDefaultVar: string; @@ -303,8 +317,42 @@ export const SOLR_STRUCTURES_COLLECTION = resolveSolrCollection({ manualFallback: 'structures', }); -export function getSolrCollection(collection: 'reactions' | 'compounds'): string { - return collection === 'reactions' ? SOLR_REACTIONS_COLLECTION : SOLR_COMPOUNDS_COLLECTION; +function resolveSolrCorpusBase(corpus: SolrCorpus): string { + const envPrefix = `NEXT_PUBLIC_SOLR_${corpus.toUpperCase()}_BASE_URL`; + const override = toNonEmpty(readEnvSafe(envPrefix)); + if (override) return ensureTrailingSlash(override); + + if (DEPLOYMENT_MODE !== 'manual') { + const modeDefault = toNonEmpty(readEnvSafe(`${envPrefix}_${DEPLOYMENT_MODE.toUpperCase()}`)); + if (modeDefault) return ensureTrailingSlash(modeDefault); + } + + // Preserve the shared legacy base when no corpus-specific base is configured. + return SOLR_BASE_LEGACY; +} + +export const SOLR_REACTIONS_BASE = resolveSolrCorpusBase('reactions'); +export const SOLR_COMPOUNDS_BASE = resolveSolrCorpusBase('compounds'); +export const SOLR_STRUCTURES_BASE = resolveSolrCorpusBase('structures'); + +export function getSolrCorpusBase(corpus: SolrCorpus): string { + switch (corpus) { + case 'reactions': return SOLR_REACTIONS_BASE; + case 'compounds': return SOLR_COMPOUNDS_BASE; + case 'structures': return SOLR_STRUCTURES_BASE; + } +} + +export function getSolrCollection(collection: SolrCorpus): string { + switch (collection) { + case 'reactions': return SOLR_REACTIONS_COLLECTION; + case 'compounds': return SOLR_COMPOUNDS_COLLECTION; + case 'structures': return SOLR_STRUCTURES_COLLECTION; + } +} + +export function solrCorpusEndpoint(corpus: SolrCorpus): string { + return `${getSolrCorpusBase(corpus)}${getSolrCollection(corpus)}`; } function readTriStateBooleanEnv(name: string): boolean | null { diff --git a/lib/api/solrSchema.ts b/lib/api/solrSchema.ts index eddfe41f..541303ae 100644 --- a/lib/api/solrSchema.ts +++ b/lib/api/solrSchema.ts @@ -10,7 +10,7 @@ * override for deployments that already know their schema. */ -import { getSolrCollection, SOLR_BASE, SOLR_NESTED_SCHEMA_OVERRIDE } from './config'; +import { SOLR_NESTED_SCHEMA_OVERRIDE, solrCorpusEndpoint } from './config'; export type BiochemCollection = 'reactions' | 'compounds'; @@ -27,7 +27,7 @@ let hasWarnedOnProbeFailure = false; async function probeNestedSchema(collection: BiochemCollection): Promise { try { - const url = `${SOLR_BASE}${getSolrCollection(collection)}/select?wt=json&rows=0&q=*:*&fq=${encodeURIComponent(parentDocTypeFilter(collection))}`; + const url = `${solrCorpusEndpoint(collection)}/select?wt=json&rows=0&q=*:*&fq=${encodeURIComponent(parentDocTypeFilter(collection))}`; const res = await fetch(url); if (!res.ok) return false; const json = await res.json(); diff --git a/lib/api/structures.ts b/lib/api/structures.ts index dffc3131..ba82a80c 100644 --- a/lib/api/structures.ts +++ b/lib/api/structures.ts @@ -1,4 +1,4 @@ -import { SOLR_BASE_LEGACY, SOLR_STRUCTURES_COLLECTION } from './config'; +import { solrCorpusEndpoint } from './config'; export interface CompoundStructure { id: string; @@ -43,7 +43,7 @@ function normalizeStructure(doc: unknown, requestedIds: Set): CompoundSt async function fetchStructureChunk(ids: string[]): Promise { const idQuery = ids.map((id) => `id:${id}`).join(' OR '); - const url = `${SOLR_BASE_LEGACY}${SOLR_STRUCTURES_COLLECTION}/select?wt=json&q=(${idQuery})&rows=${ids.length}&fl=id,smiles,inchi,inchikey,svg`; + const url = `${solrCorpusEndpoint('structures')}/select?wt=json&q=(${idQuery})&rows=${ids.length}&fl=id,smiles,inchi,inchikey,svg`; const response = await fetch(url); if (!response.ok) return undefined; return response.json(); diff --git a/next.config.ts b/next.config.ts index d3296a6a..bb990e4b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,10 +1,17 @@ import type { NextConfig } from "next"; +const solrProxyUpstream = process.env.SOLR_PROXY_UPSTREAM?.trim().replace(/\/+$/, ""); + const nextConfig: NextConfig = { output: "standalone", images: { unoptimized: true, }, + // Internal Solr hosts omit CORS headers, while the app fetches Solr in the browser. + async rewrites() { + if (!solrProxyUpstream) return []; + return [{ source: "/solr/:path*", destination: `${solrProxyUpstream}/:path*` }]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index 2591c184..5acee4e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modelseed-ui", - "version": "3.3.0", + "version": "3.4.0", "private": true, "scripts": { "predev": "node scripts/sync-version-from-env.mjs", diff --git a/tests/unit/api/config.test.ts b/tests/unit/api/config.test.ts index 7dcf7039..869060f8 100644 --- a/tests/unit/api/config.test.ts +++ b/tests/unit/api/config.test.ts @@ -12,8 +12,18 @@ function clearEndpointOverrides(): void { vi.stubEnv('NEXT_PUBLIC_REST_BASE_URL', ''); vi.stubEnv('NEXT_PUBLIC_STATUS_API_URL', ''); vi.stubEnv('NEXT_PUBLIC_SOLR_BASE_URL', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_STAGING', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_PRODUCTION', ''); vi.stubEnv('NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_STAGING', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_PRODUCTION', ''); vi.stubEnv('NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_STAGING', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_PRODUCTION', ''); + vi.stubEnv('NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION', ''); } describe('api config deployment resolution', () => { @@ -90,4 +100,46 @@ describe('api config deployment resolution', () => { expect(config.MODELSEED_API_URL).toBe('https://custom-staging.modelseed.org/PMS'); expect(config.SOLR_REACTIONS_COLLECTION).toBe('reactions_custom_staging'); }); + + it('uses an explicit structures base override over the shared base', async () => { + clearEndpointOverrides(); + vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'staging'); + vi.stubEnv('NEXT_PUBLIC_SOLR_BASE_URL', 'https://shared.example/solr'); + vi.stubEnv('NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL', 'https://structures.example/solr/'); + const config = await loadConfig(); + expect(config.SOLR_STRUCTURES_BASE).toBe('https://structures.example/solr/'); + expect(config.SOLR_BASE_LEGACY).toBe('https://shared.example/solr/'); + }); + + it('uses a mode-specific corpus base when no explicit override is set', async () => { + clearEndpointOverrides(); + vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'staging'); + vi.stubEnv('NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_STAGING', 'https://structures-staging.example/solr'); + const config = await loadConfig(); + expect(config.SOLR_STRUCTURES_BASE).toBe('https://structures-staging.example/solr/'); + }); + + it('falls back to the legacy base for all corpora when none are configured', async () => { + clearEndpointOverrides(); + const config = await loadConfig(); + expect(config.SOLR_REACTIONS_BASE).toBe(config.SOLR_BASE_LEGACY); + expect(config.SOLR_COMPOUNDS_BASE).toBe(config.SOLR_BASE_LEGACY); + expect(config.SOLR_STRUCTURES_BASE).toBe(config.SOLR_BASE_LEGACY); + }); + + it('does not change reactions or compounds when structures base is configured', async () => { + clearEndpointOverrides(); + vi.stubEnv('NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL', 'https://structures.example/solr'); + const config = await loadConfig(); + expect(config.SOLR_REACTIONS_BASE).toBe(config.SOLR_BASE_LEGACY); + expect(config.SOLR_COMPOUNDS_BASE).toBe(config.SOLR_BASE_LEGACY); + }); + + it('concatenates a structures corpus endpoint with exactly one slash', async () => { + clearEndpointOverrides(); + vi.stubEnv('NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL', 'https://structures.example/solr///'); + vi.stubEnv('NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION', 'structures_custom'); + const config = await loadConfig(); + expect(config.solrCorpusEndpoint('structures')).toBe('https://structures.example/solr/structures_custom'); + }); }); From 5f449572b3ef5dc6da107840e997405e0e57e7ea Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 21 Aug 2026 19:20:02 -0500 Subject: [PATCH 29/34] feat(ui): streamline atom mapping colors and participant metadata --- .../biochem/reactions/[id]/page.tsx | 3 - components/ui/MoleculeRenderer.tsx | 24 +------ components/ui/ReactionStructureEquation.tsx | 70 +++++++++--------- lib/utils/moleculeHighlights.ts | 24 +++++++ .../unit/components/MoleculeRenderer.test.tsx | 12 +++- .../ReactionStructureEquation.test.tsx | 72 +++++++++---------- tests/unit/utils/moleculeHighlights.test.ts | 21 ++++++ 7 files changed, 125 insertions(+), 101 deletions(-) diff --git a/app/(reference-data)/biochem/reactions/[id]/page.tsx b/app/(reference-data)/biochem/reactions/[id]/page.tsx index 39a8d436..3741dcd3 100644 --- a/app/(reference-data)/biochem/reactions/[id]/page.tsx +++ b/app/(reference-data)/biochem/reactions/[id]/page.tsx @@ -386,9 +386,6 @@ export default function ReactionDetailPage() { )} {atomPairs.length > 0 && ( - - Raw mapping entries are available as supporting detail. - 0) { - const atomIndices = Object.keys(currentAtomColors).map(Number); - const highlightColors: Record = {}; - - for (const idx of atomIndices) { - // Convert CSS hex color (#rrggbb) to RDKit [r, g, b] floats - const hex = currentAtomColors[idx].replace('#', ''); - const r = parseInt(hex.slice(0, 2), 16) / 255; - const g = parseInt(hex.slice(2, 4), 16) / 255; - const b = parseInt(hex.slice(4, 6), 16) / 255; - highlightColors[idx] = [r, g, b]; - } - - svg = mol.get_svg_with_highlights( - JSON.stringify({ - atoms: atomIndices, - bonds: [], - highlightAtomColors: highlightColors, - width, - height, - }) - ); + svg = applyAtomLabelColors(mol.get_svg(width, height), currentAtomColors); } else { svg = mol.get_svg(width, height); } diff --git a/components/ui/ReactionStructureEquation.tsx b/components/ui/ReactionStructureEquation.tsx index 19bb04e2..2bc85c5c 100644 --- a/components/ui/ReactionStructureEquation.tsx +++ b/components/ui/ReactionStructureEquation.tsx @@ -123,20 +123,23 @@ interface CompoundColumnProps { mappingDescription?: string; mappingControls?: Readonly>; highlightedGroup?: string; - onHighlight: (groupId: string) => void; - onClearHighlight: () => void; - onSelectGroup: (groupId: string) => void; onInventory: (inventory: Inventory) => void; onGraph: (graph: HeavyAtomGraph) => void; - isLoading: boolean; precisionResult?: CompoundColorResult; precisionControlId?: string; + isLoading: boolean; precisionResult?: CompoundColorResult; } -function CompoundColumn({ token, data, structure, atomColors, bondColors, mappingDescription, mappingControls, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup, onInventory, onGraph, isLoading, precisionResult, precisionControlId }: CompoundColumnProps) { - const [precisionExpanded, setPrecisionExpanded] = useState(false); +function CompoundColumn({ token, data, structure, atomColors, bondColors, mappingDescription, mappingControls, highlightedGroup, onInventory, onGraph, isLoading, precisionResult }: CompoundColumnProps) { const smiles = data?.smiles ?? structure?.smiles; const drawStructure = Boolean(structure?.svg) || (Boolean(smiles) && (!data?.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1)); const label = data?.name || token.id; - const metadata = [token.id, data?.formula, formatCharge(data?.charge)].filter(Boolean).join(' · '); + const metadata = token.id; + const accessibleDescription = [ + data?.formula && `Formula ${data.formula}`, + formatCharge(data?.charge) && `charge ${formatCharge(data?.charge)}`, + precisionResult && PRECISION_LABELS[precisionResult.precision], + precisionResult && precisionExplanation(precisionResult), + mappingDescription, + ].filter(Boolean).join(' · '); const contents = isLoading ? ( ) : drawStructure ? ( @@ -150,7 +153,7 @@ function CompoundColumn({ token, data, structure, atomColors, bondColors, mappin onGraph={onGraph} width={134} height={134} - alt={mappingDescription ? `Structure of ${label}; ${mappingDescription}` : `Structure of ${label}`} + alt={accessibleDescription ? `Structure of ${label}; ${accessibleDescription}` : `Structure of ${label}`} /> ) : ( @@ -161,38 +164,28 @@ function CompoundColumn({ token, data, structure, atomColors, bondColors, mappin const isDimmed = Boolean(highlightedGroup && !isMember); const highlightColor = isMember ? Object.values(mappingControls ?? {}).find((control) => control.groupId === highlightedGroup)?.color : undefined; return ( - - {token.stoich && {token.stoich}} + + {token.stoich && {token.stoich}} - + {contents} {isLoading ? : - {label} + {label} } {metadata} - {!isLoading && precisionResult && precisionControlId && setPrecisionExpanded(true)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setPrecisionExpanded((expanded) => !expanded); } }} onClick={() => setPrecisionExpanded((expanded) => !expanded)} sx={{ border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', color: 'text.secondary', font: 'inherit', textDecoration: 'underline', overflowWrap: 'anywhere' }}>{PRECISION_LABELS[precisionResult.precision]}{precisionExpanded && {precisionExplanation(precisionResult)}}} - {!isLoading && Object.keys(mappingControls ?? {}).length > 0 && - {Object.entries(mappingControls ?? {}).map(([element, control]) => { - const color = control.color; - return onSelectGroup(control.groupId)} onMouseEnter={() => onHighlight(control.groupId)} onMouseLeave={onClearHighlight} onFocus={() => onHighlight(control.groupId)} onBlur={onClearHighlight} sx={{ display: 'flex', alignItems: 'center', gap: 0.25, border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', font: 'inherit' }}> - ; - })} - } ); } -function EquationSide({ tokens, displayMap, structures, atomMapping, useOrbitColors, plan, orbitPlan, callbacks, graphCallbacks, isLoading, highlightedGroup, onHighlight, onClearHighlight, onSelectGroup, side }: { +function EquationSide({ tokens, displayMap, structures, atomMapping, useOrbitColors, plan, orbitPlan, callbacks, graphCallbacks, isLoading, highlightedGroup }: { tokens: CompoundToken[]; displayMap: Map; structures: Map; atomMapping?: ReactionAtomMapping; useOrbitColors: boolean; plan: ReturnType; orbitPlan: ReturnType; callbacks: Readonly void>>; graphCallbacks: Readonly void>>; - isLoading: boolean; side: string; highlightedGroup?: string; onHighlight: (groupId: string) => void; onClearHighlight: () => void; onSelectGroup: (groupId: string) => void; + isLoading: boolean; highlightedGroup?: string; }) { return {tokens.map((token, index) => { @@ -204,7 +197,7 @@ function EquationSide({ tokens, displayMap, structures, atomMapping, useOrbitCol return + mappingDescription={descriptions.join('; ') || undefined} mappingControls={mappingControls} highlightedGroup={highlightedGroup} onInventory={callbacks[token.id]} onGraph={graphCallbacks[token.id]} isLoading={isLoading} precisionResult={precisionResult} /> {index < tokens.length - 1 && } ; })} @@ -277,36 +270,39 @@ export default function ReactionStructureEquation({ equation, reversibility, ato return { if (event.key === 'Escape') setSelectedGroup(undefined); }}> - setHoveredGroup(undefined)} onSelectGroup={selectGroup} side="reactant" /> + - setHoveredGroup(undefined)} onSelectGroup={selectGroup} side="product" /> + {error && Compound details could not be loaded.} {useOrbitColors && !isLoading && !structuresLoading && orbitPlan.groups.length > 0 && {(plan.colorableCount > 0 || atomMappingConfidence || atomMappingHasSymmetryGroups) && {orbitPlan.groups.length > 0 && Atom mapping} {atomMappingConfidence && } - {atomMappingHasSymmetryGroups && A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined.} } - {(() => { - const summary = (['exact-atom', 'symmetry-orbit', 'element-block', 'unresolved'] as MappingPrecision[]) - .filter((precision) => orbitPlan.precisionSummary[precision] > 0) - .map((precision) => `${orbitPlan.precisionSummary[precision]} ${PRECISION_LABELS[precision].toLowerCase()}`) - .join('; '); - return summary && Precision: {summary}.; - })()} {orbitPlan.groups.map((entry) => { const description = `${entry.elements.join(', ')}: ${joinCompoundIds(entry.compoundIds)}${entry.hasSymmetryGroup ? ' — grouped; individual atom pairing is not determined by the data' : ''}`; return selectGroup(entry.groupId)} onMouseEnter={() => setHoveredGroup(entry.groupId)} onMouseLeave={() => setHoveredGroup(undefined)} onFocus={() => setHoveredGroup(entry.groupId)} onBlur={() => setHoveredGroup(undefined)} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', textAlign: 'left' }}> ; })} - {reasons.length > 0 && Some atoms could not be unambiguously mapped and therefore are not coloured: {reasons.join('; ')}.} + + Mapping details + {atomMappingHasSymmetryGroups && A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined.} + {(() => { + const summary = (['exact-atom', 'symmetry-orbit', 'element-block', 'unresolved'] as MappingPrecision[]) + .filter((precision) => orbitPlan.precisionSummary[precision] > 0) + .map((precision) => `${orbitPlan.precisionSummary[precision]} ${PRECISION_LABELS[precision].toLowerCase()}`) + .join('; '); + return summary && Precision: {summary}.; + })()} + {reasons.length > 0 && Some atoms could not be unambiguously mapped and therefore are not coloured: {reasons.join('; ')}.} + } {structureError && Structure data could not be loaded, so atom-level mapping precision is unavailable and any colours shown are element-level at best.} ; diff --git a/lib/utils/moleculeHighlights.ts b/lib/utils/moleculeHighlights.ts index 2bedb010..6f88b44c 100644 --- a/lib/utils/moleculeHighlights.ts +++ b/lib/utils/moleculeHighlights.ts @@ -92,6 +92,30 @@ export function buildMoleculeHighlightPlan( return { atomColors, bondColors }; } +export function applyAtomLabelColors(svg: string, atomColors: Readonly>): string { + if (typeof svg !== 'string' || !atomColors || Object.keys(atomColors).length === 0) return svg; + + try { + return svg.replace(/<[^>]+>/g, (tag) => { + const classMatch = /\bclass=(['"])(.*?)\1/.exec(tag); + if (!classMatch) return tag; + const atomMatch = /^atom-(\d+)(?:\s|$)/.exec(classMatch[2]); + if (!atomMatch) return tag; + const color = atomColors[Number(atomMatch[1])]; + if (typeof color !== 'string') return tag; + return tag + .replace(/\bstyle=(['"])(.*?)\1/, (_styleAttribute, quote, style) => ( + `style=${quote}${style.replace(/fill:\s*#[0-9a-f]{6}/gi, `fill:${color}`)}${quote}` + )) + .replace(/\bfill=(['"])#[0-9a-f]{6}\1/gi, (_fillAttribute, quote) => ( + `fill=${quote}${color}${quote}` + )); + }); + } catch { + return svg; + } +} + export function applyBondColors(svg: string, bondColors: Readonly>): string { if (typeof svg !== 'string' || !bondColors || Object.keys(bondColors).length === 0) return svg; diff --git a/tests/unit/components/MoleculeRenderer.test.tsx b/tests/unit/components/MoleculeRenderer.test.tsx index 4c8c76be..2d41c7ba 100644 --- a/tests/unit/components/MoleculeRenderer.test.tsx +++ b/tests/unit/components/MoleculeRenderer.test.tsx @@ -4,8 +4,8 @@ import MoleculeRenderer from '@/components/ui/MoleculeRenderer'; const getMol = vi.fn(() => ({ get_json: () => JSON.stringify({ molecules: [{ atoms: [{ z: 8 }, { z: 15 }], bonds: [{ atoms: [0, 1] }] }] }), - get_svg: () => "", - get_svg_with_highlights: () => "", + get_svg: vi.fn(() => ""), + get_svg_with_highlights: vi.fn(() => ""), delete: vi.fn(), })); @@ -17,6 +17,14 @@ describe('MoleculeRenderer', () => { await waitFor(() => expect(container.innerHTML).toContain('stroke:#123456')); }); + it('colours atom labels without requesting RDKit highlights', async () => { + const { container } = render(); + await waitFor(() => expect(container.querySelector('[class="atom-0"]')?.getAttribute('fill')).toBe('#123456')); + const mol = getMol.mock.results.at(-1)?.value; + expect(mol.get_svg).toHaveBeenCalled(); + expect(mol.get_svg_with_highlights).not.toHaveBeenCalled(); + }); + it('renders a stored SVG unmodified when no SMILES is available', () => { const fallbackSvg = ''; const { container } = render(); diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx index 4c37c36b..c130d555 100644 --- a/tests/unit/components/ReactionStructureEquation.test.tsx +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -62,7 +62,7 @@ describe('ReactionStructureEquation', () => { const { container } = renderEquation({ atomMappingPairs: pairs }); await waitFor(() => expect(container.textContent).toContain('Atom mapping')); expect(container.querySelectorAll('[aria-label="Atom mapping legend"] li').length).toBeGreaterThan(0); - expect(container.textContent).toContain('individual atom pairing is not determined by the data'); + expect(container.querySelector('[aria-label="Atom mapping legend"] button[aria-label*="individual atom pairing"]')).toBeTruthy(); expect(rendererCalls.some((call) => call.elementColors)).toBe(false); }); @@ -151,16 +151,15 @@ describe('ReactionStructureEquation', () => { expect([...pluses, ...arrows].every((node) => node.getAttribute('aria-hidden') === 'true')).toBe(true); }); - it('renders the compound ID and formula as secondary text beneath the prominent name', async () => { + it('renders the compound ID as secondary text and keeps formula in the accessible description', async () => { const { container, getByText } = renderEquation(); await waitFor(() => expect(getByText('Phosphate donor')).toBeTruthy()); const caption = Array.from(container.querySelectorAll('.MuiTypography-caption')) - .find((node) => node.textContent?.includes('cpd00012') && node.textContent.includes('H4O7P2')); - const name = getByText('Phosphate donor', { selector: 'a p' }); + .find((node) => node.textContent === 'cpd00012'); + const structure = rendererCalls.filter((call) => call.compoundId === 'cpd00012').at(-1); expect(caption).toBeTruthy(); - expect(caption?.textContent).toContain('cpd00012'); - expect(caption?.textContent).toContain('H4O7P2'); - expect(name).not.toBe(caption); + expect(caption?.textContent).not.toContain('H4O7P2'); + expect(structure?.alt).toContain('Formula H4O7P2'); }); it('draws a compound with a structural SMILES and at least one heavy formula atom', async () => { @@ -240,9 +239,8 @@ describe('ReactionStructureEquation', () => { it('formats zero and negative compound charges as intended', async () => { const { container } = renderEquation(); await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00012"]')).toBeTruthy()); - const captions = Array.from(container.querySelectorAll('.MuiTypography-caption')).map((node) => node.textContent); - expect(captions.some((caption) => caption === 'cpd00001 · H2O')).toBe(true); - expect(captions.some((caption) => caption?.includes('cpd00012 · H4O7P2 · 2-'))).toBe(true); + expect(rendererCalls.filter((call) => call.compoundId === 'cpd00001').at(-1)?.alt).toContain('Formula H2O'); + expect(rendererCalls.filter((call) => call.compoundId === 'cpd00012').at(-1)?.alt).toContain('charge 2-'); }); it('does not render a stoichiometry coefficient of one', async () => { @@ -300,8 +298,8 @@ describe('ReactionStructureEquation', () => { expect(container.textContent).toContain(id); expect(container.querySelector(`a[href="/biochem/compounds/${id}"]`)).toBeTruthy(); } - expect(getByText(/O: cpd00001, cpd00011 and cpd00742 — grouped/)).toBeTruthy(); - expect(container.textContent).toContain('individual atom pairing is not determined by the data'); + expect(getByText(/O · cpd00001, cpd00011, cpd00742 †/)).toBeTruthy(); + expect(container.querySelector('[aria-label="Atom mapping legend"] button[aria-label*="individual atom pairing"]')).toBeTruthy(); await waitFor(() => expect(rendererCalls.some((call) => call.compoundId === 'cpd00011' && call.onGraph)).toBe(true)); const water = getByTestId('structure-cpd00001'); const allophanate = getByTestId('structure-cpd00742'); @@ -344,11 +342,14 @@ describe('ReactionStructureEquation', () => { expect(carbon.getAttribute('aria-pressed')).toBe('false'); }); - it('exposes element indicators as named mapping-group buttons', async () => { + it('uses the legend as the only operable mapping-group control', async () => { const rxnPairs = parseAtomMappings(['cpd00742:C#1=cpd00011:C#1', 'cpd00742:C#2=cpd00011:C#1']); vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], ['cpd00011', compound({ name: 'CO2', smiles: 'O=C=O', formula: 'CO2', charge: 0 })]])); - const { getAllByRole } = renderEquation({ equation: 'cpd00742[c] => cpd00011[c]', atomMappingPairs: rxnPairs }); - await waitFor(() => expect(getAllByRole('button', { name: 'Highlight C mapping group' }).length).toBeGreaterThan(0)); + const { getByRole, queryByRole } = renderEquation({ equation: 'cpd00742[c] => cpd00011[c]', atomMappingPairs: rxnPairs }); + const legend = await waitFor(() => getByRole('button', { name: /^C:/ })); + expect(queryByRole('button', { name: /Highlight .* mapping group/ })).toBeNull(); + fireEvent.click(legend); + expect(legend.getAttribute('aria-pressed')).toBe('true'); }); it('keeps the selected group after pointer hover leaves another control', async () => { @@ -390,35 +391,32 @@ describe('ReactionStructureEquation', () => { } } - it('renders exact, symmetry-orbit, element-level, and unresolved precision labels', async () => { + it('keeps precision labels and explanations in accessible descriptions and Mapping details', async () => { vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([ ['cpd00001', { id: 'cpd00001', inchi: 'InChI=1S/H2O/h1H2' }], ['cpd00009', { id: 'cpd00009', inchi: 'InChI=1S/H3O4P/c1-5(2,3)4/h(H3,1,2,3,4)/p-2' }], ])); - const { getByRole, container } = renderEquation({ equation: 'cpd00001[c] => cpd00009[c]', atomMappingPairs: parseAtomMappings(['cpd00001:O#1=cpd00009:O#1']) }); + const { getByText, queryByRole } = renderEquation({ equation: 'cpd00001[c] => cpd00009[c]', atomMappingPairs: parseAtomMappings(['cpd00001:O#1=cpd00009:O#1']) }); await provideGraphs(); - await waitFor(() => expect(getByRole('button', { name: 'Water: Exact atom mapping' })).toBeTruthy()); - expect(getByRole('button', { name: 'Phosphate: Symmetry-equivalent atoms' })).toBeTruthy(); - const precisionSummary = Array.from(container.querySelectorAll('.MuiTypography-caption')).find((node) => node.textContent?.startsWith('Precision:')); - expect(precisionSummary?.textContent).toContain('Precision:'); - expect(precisionSummary?.textContent).not.toContain('0 '); - - // Unsupported InChI can honestly colour a complete element block, while absent structure remains unresolved. - vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([['cpd00001', { id: 'cpd00001', inchi: 'InChI=1S/p+1' }]])); - const elementBlock = renderEquation({ equation: 'cpd00001[c] => cpd00009[c]', atomMappingPairs: parseAtomMappings(['cpd00001:O#1=cpd00009:O#1']) }); - await provideGraphs(); - await waitFor(() => expect(elementBlock.getByRole('button', { name: 'Water: Element-level mapping' })).toBeTruthy()); - expect(elementBlock.getByRole('button', { name: 'Phosphate: No atom mapping shown' })).toBeTruthy(); + await waitFor(() => expect(rendererCalls.filter((call) => call.compoundId === 'cpd00001').at(-1)?.alt).toContain('Exact atom mapping')); + expect(rendererCalls.filter((call) => call.compoundId === 'cpd00001').at(-1)?.alt).toContain('Each colour identifies the exact mapped atom.'); + expect(queryByRole('button', { name: /mapping$/ })).toBeNull(); + expect(getByText('Mapping details').closest('details')?.textContent).toContain('Precision:'); }); - it('associates a keyboard-activated precision control with its explanation', async () => { - vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([['cpd00001', { id: 'cpd00001', inchi: 'InChI=1S/H2O/h1H2' }]])); - const { getAllByRole } = renderEquation({ equation: 'cpd00001[c] => cpd00001[c]', atomMappingPairs: parseAtomMappings(['cpd00001:O#1=cpd00001:O#1']) }); - await provideGraphs(); - const control = await waitFor(() => getAllByRole('button', { name: 'Water: Exact atom mapping' })[0]); - fireEvent.keyDown(control, { key: 'Enter' }); - expect(control.getAttribute('aria-expanded')).toBe('true'); - expect(document.getElementById(control.getAttribute('aria-describedby') ?? '')?.textContent).toContain('exact mapped atom'); + it('colours highlighted participant names without outlining token boxes', async () => { + const { container, getByRole, getByText } = renderEquation({ atomMappingPairs: pairs }); + const phosphorus = await waitFor(() => getByRole('button', { name: /^P:/ })); + fireEvent.click(phosphorus); + const token = container.querySelector('[data-mapping-token="cpd00009"]') as HTMLElement; + expect(token.style.outline).toBe(''); + expect((getByText('Phosphate', { selector: 'a p' }) as HTMLElement).style.color).not.toBe(''); + }); + + it('renders Mapping details with the precision summary', async () => { + const { getByText } = renderEquation({ atomMappingPairs: pairs }); + const details = await waitFor(() => getByText('Mapping details').closest('details')); + expect(details?.textContent).toContain('Precision:'); }); it('keeps participants visible and reports a structures-query error while preserving honest element-level colours', async () => { diff --git a/tests/unit/utils/moleculeHighlights.test.ts b/tests/unit/utils/moleculeHighlights.test.ts index 58a2b60f..5ea0380f 100644 --- a/tests/unit/utils/moleculeHighlights.test.ts +++ b/tests/unit/utils/moleculeHighlights.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + applyAtomLabelColors, applyBondColors, buildMoleculeHighlightPlan, elementInventoryFromMolJson, @@ -49,6 +50,26 @@ describe('moleculeHighlights', () => { expect(plan.bondColors).toEqual({ 0: '#ff0000' }); }); + it('recolours atom label fill attributes and styles', () => { + const fillAttribute = ""; + const fillStyle = ""; + expect(applyAtomLabelColors(fillAttribute, { 2: '#00ff00' })).toContain("fill='#00ff00'"); + expect(applyAtomLabelColors(fillStyle, { 3: '#abcdef' })).toContain('fill:#abcdef;stroke:#000000'); + }); + + it('does not recolour bond tags or unmapped atom labels', () => { + const bond = ""; + const unmappedAtom = ""; + expect(applyAtomLabelColors(bond, { 0: '#00ff00' })).toBe(bond); + expect(applyAtomLabelColors(unmappedAtom, { 1: '#00ff00' })).toBe(unmappedAtom); + }); + + it('returns the identical SVG for an empty atom colour map or SVG', () => { + const atomLabel = ""; + expect(applyAtomLabelColors(atomLabel, {})).toBe(atomLabel); + expect(applyAtomLabelColors('', { 2: '#00ff00' })).toBe(''); + }); + it('recolours a bond stroke without changing its path or fill', () => { const result = applyBondColors(BOND_PATH, { 0: '#00ff00' }); expect(result).toContain('stroke:#00ff00'); From d6317fdf0a3afd2c3fae1f55347bc2b433c1b95f Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Mon, 24 Aug 2026 07:44:47 -0500 Subject: [PATCH 30/34] feat(ui): label every carbon in mapped reaction structures Seaver: atoms in the reaction-page SVGs were not individually identifiable because RDKit renders carbons as bare skeletal vertices, so a per-atom mapping colour on a carbon had no glyph to land on. - add buildExplicitAtomLabels() to force an explicit "C" label on plain carbons (skips charged/isotopic atoms so native labels are kept) - MoleculeRenderer gains opt-in showAllAtomLabels and renders the base SVG through get_svg_with_highlights({atomLabels}); the existing applyAtomLabelColors/applyBondColors recolouring is unchanged - ReactionStructureEquation enables it for every equation compound - drop the redundant AtomMappingSummary subsection and its test; the in-place legend and the collapsed "Mapping details" caveat remain Mapping semantics are untouched: colours still come from the existing orbit/mapping plan, and unmapped atoms stay uncoloured. --- .../biochem/reactions/[id]/page.tsx | 10 -- components/ui/AtomMappingSummary.tsx | 119 ------------------ components/ui/MoleculeRenderer.tsx | 20 ++- components/ui/ReactionStructureEquation.tsx | 6 +- lib/utils/moleculeHighlights.ts | 17 +++ .../components/AtomMappingSummary.test.tsx | 80 ------------ .../unit/components/MoleculeRenderer.test.tsx | 16 +++ .../ReactionStructureEquation.test.tsx | 13 ++ tests/unit/utils/moleculeHighlights.test.ts | 21 ++++ 9 files changed, 86 insertions(+), 216 deletions(-) delete mode 100644 components/ui/AtomMappingSummary.tsx delete mode 100644 tests/unit/components/AtomMappingSummary.test.tsx diff --git a/app/(reference-data)/biochem/reactions/[id]/page.tsx b/app/(reference-data)/biochem/reactions/[id]/page.tsx index 3741dcd3..999abc4a 100644 --- a/app/(reference-data)/biochem/reactions/[id]/page.tsx +++ b/app/(reference-data)/biochem/reactions/[id]/page.tsx @@ -15,7 +15,6 @@ import { getReactionById, EXTERNAL_DBS } from '@/lib/api/biochem'; import ChemicalEquation from '@/components/ui/ChemicalEquation'; import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation'; import ThermodynamicsTable from '@/components/ui/ThermodynamicsTable'; -import AtomMappingSummary from '@/components/ui/AtomMappingSummary'; import { normalizeAtomMapping, parseAtomMappings } from '@/lib/utils/atomMapping'; import { directionAgreementFromRecords, @@ -384,15 +383,6 @@ export default function ReactionDetailPage() { atomMappingHasSymmetryGroups={atomMapping.hasSymmetryGroups} /> )} - {atomPairs.length > 0 && ( - - - - )} diff --git a/components/ui/AtomMappingSummary.tsx b/components/ui/AtomMappingSummary.tsx deleted file mode 100644 index 1b8d3c42..00000000 --- a/components/ui/AtomMappingSummary.tsx +++ /dev/null @@ -1,119 +0,0 @@ -'use client'; - -import { useMemo, useState } from 'react'; -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import Chip from '@mui/material/Chip'; -import Collapse from '@mui/material/Collapse'; -import Button from '@mui/material/Button'; -import NextLink from 'next/link'; -import { - parseAtomMappings, - groupAtomMappingsByCompound, - countAtomsPerElement, - formatAtomGroup, -} from '@/lib/utils/atomMapping'; - -export interface AtomMappingSummaryProps { - entries: readonly string[] | undefined; - confidence?: string; - hasSymmetryGroups?: boolean; -} - -function confidenceColor(value: string): 'success' | 'warning' | 'default' { - if (value === 'clean') return 'success'; - if (value === 'salvaged') return 'warning'; - return 'default'; -} - -const compoundLinkStyle = { color: '#00838f', textDecoration: 'none', fontWeight: 600 }; - -export default function AtomMappingSummary({ - entries, - confidence, - hasSymmetryGroups, -}: AtomMappingSummaryProps) { - const pairs = useMemo(() => parseAtomMappings(entries), [entries]); - const grouped = useMemo(() => pairs.filter((pair) => pair.hasSymmetryGroup), [pairs]); - const [showAll, setShowAll] = useState(false); - - if (pairs.length === 0) return null; - - const groups = groupAtomMappingsByCompound(pairs); - const compoundIds = Array.from(groups.keys()); - const elementCounts = countAtomsPerElement(pairs); - - return ( - - - - {pairs.length} atom mappings across {compoundIds.length} compounds - - {typeof confidence === 'string' && confidence.length > 0 && ( - - )} - - - {(hasSymmetryGroups || grouped.length > 0) && ( - - - - A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined. - - - {grouped.length} of {pairs.length} mappings resolve to a symmetry-equivalent group - - - )} - - - {compoundIds.map((compoundId) => { - const counts = elementCounts.get(compoundId); - const countText = counts - ? Array.from(counts.entries()) - .map(([element, count]) => `${element} x${count}`) - .join(', ') - : ''; - return ( - - - {compoundId} - - - : {countText} - - - ); - })} - - - - - - - {pairs.map((pair, index) => ( - - {pair.leftAtoms.length > 1 ? 'any of ' : ''}{formatAtomGroup(pair.leftAtoms)} - {' = '} - {pair.rightAtoms.length > 1 ? 'any of ' : ''}{formatAtomGroup(pair.rightAtoms)} - - ))} - - - - - ); -} diff --git a/components/ui/MoleculeRenderer.tsx b/components/ui/MoleculeRenderer.tsx index b891c4c8..db6273fa 100644 --- a/components/ui/MoleculeRenderer.tsx +++ b/components/ui/MoleculeRenderer.tsx @@ -6,7 +6,7 @@ import Skeleton from '@mui/material/Skeleton'; import Typography from '@mui/material/Typography'; import { getRDKit } from '@/lib/rdkit'; import { getCompoundImageUrl } from '@/lib/api/biochem'; -import { applyAtomLabelColors, applyBondColors, buildMoleculeHighlightPlan, elementInventoryFromMolJson, elementSymbolForAtomicNumber } from '@/lib/utils/moleculeHighlights'; +import { applyAtomLabelColors, applyBondColors, buildExplicitAtomLabels, buildMoleculeHighlightPlan, elementInventoryFromMolJson, elementSymbolForAtomicNumber } from '@/lib/utils/moleculeHighlights'; import type { HeavyAtomGraph } from '@/lib/utils/inchiAtomOrder'; /** @@ -28,6 +28,8 @@ interface MoleculeRendererProps { onInventory?: (inventory: Record) => void; /** Optional per-bond color map, keyed by the RDKit graph bond-array index. */ bondColors?: Record; + /** Forces explicit carbon labels for atom-level mapping readability. */ + showAllAtomLabels?: boolean; /** Called after a successful RDKit parse with the molecule's local heavy-atom graph. */ onGraph?: (graph: HeavyAtomGraph) => void; /** Plain stored SVG used only when a local RDKit SVG cannot be produced. */ @@ -46,6 +48,7 @@ export default function MoleculeRenderer({ elementColors, onInventory, bondColors, + showAllAtomLabels = false, onGraph, fallbackSvg, width = 150, @@ -98,7 +101,7 @@ export default function MoleculeRenderer({ const currentElementColors = elementColorsRef.current; const currentAtomColors = atomColorsRef.current; const currentBondColors = bondColorsRef.current; - if ((currentElementColors && Object.keys(currentElementColors).length > 0) || onInventoryRef.current || onGraphRef.current) { + if ((currentElementColors && Object.keys(currentElementColors).length > 0) || onInventoryRef.current || onGraphRef.current || showAllAtomLabels) { try { molJson = JSON.parse(mol.get_json()); onInventoryRef.current?.(elementInventoryFromMolJson(molJson)); @@ -146,9 +149,16 @@ export default function MoleculeRenderer({ svg = mol.get_svg(width, height); } } else if (currentAtomColors && Object.keys(currentAtomColors).length > 0) { - svg = applyAtomLabelColors(mol.get_svg(width, height), currentAtomColors); + const atomLabels = showAllAtomLabels ? buildExplicitAtomLabels(molJson) : {}; + const baseSvg = Object.keys(atomLabels).length > 0 + ? mol.get_svg_with_highlights(JSON.stringify({ width, height, atomLabels })) + : mol.get_svg(width, height); + svg = applyAtomLabelColors(baseSvg, currentAtomColors); } else { - svg = mol.get_svg(width, height); + const atomLabels = showAllAtomLabels ? buildExplicitAtomLabels(molJson) : {}; + svg = Object.keys(atomLabels).length > 0 + ? mol.get_svg_with_highlights(JSON.stringify({ width, height, atomLabels })) + : mol.get_svg(width, height); } if (currentBondColors && Object.keys(currentBondColors).length > 0) { @@ -180,7 +190,7 @@ export default function MoleculeRenderer({ return () => { cancelled = true; }; - }, [smiles, atomColorsKey, elementColorsKey, bondColorsKey, fallbackSvg, width, height]); + }, [smiles, atomColorsKey, elementColorsKey, bondColorsKey, showAllAtomLabels, fallbackSvg, width, height]); if (state === 'loading') { return ( diff --git a/components/ui/ReactionStructureEquation.tsx b/components/ui/ReactionStructureEquation.tsx index 2bc85c5c..99064d38 100644 --- a/components/ui/ReactionStructureEquation.tsx +++ b/components/ui/ReactionStructureEquation.tsx @@ -120,6 +120,7 @@ interface CompoundColumnProps { structure?: CompoundStructure; atomColors?: AtomColors; bondColors?: Record; + showAllAtomLabels?: boolean; mappingDescription?: string; mappingControls?: Readonly>; highlightedGroup?: string; @@ -128,7 +129,7 @@ interface CompoundColumnProps { isLoading: boolean; precisionResult?: CompoundColorResult; } -function CompoundColumn({ token, data, structure, atomColors, bondColors, mappingDescription, mappingControls, highlightedGroup, onInventory, onGraph, isLoading, precisionResult }: CompoundColumnProps) { +function CompoundColumn({ token, data, structure, atomColors, bondColors, showAllAtomLabels, mappingDescription, mappingControls, highlightedGroup, onInventory, onGraph, isLoading, precisionResult }: CompoundColumnProps) { const smiles = data?.smiles ?? structure?.smiles; const drawStructure = Boolean(structure?.svg) || (Boolean(smiles) && (!data?.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1)); const label = data?.name || token.id; @@ -148,6 +149,7 @@ function CompoundColumn({ token, data, structure, atomColors, bondColors, mappin compoundId={token.id} atomColors={atomColors} bondColors={bondColors} + showAllAtomLabels={showAllAtomLabels} fallbackSvg={structure?.svg} onInventory={onInventory} onGraph={onGraph} @@ -196,7 +198,7 @@ function EquationSide({ tokens, displayMap, structures, atomMapping, useOrbitCol const descriptions = tokenBlocks.map((block) => `${block.element} mapped to ${block.counterpartCompoundIds.join(', ')}`); return {index < tokens.length - 1 && } ; diff --git a/lib/utils/moleculeHighlights.ts b/lib/utils/moleculeHighlights.ts index 6f88b44c..a2690696 100644 --- a/lib/utils/moleculeHighlights.ts +++ b/lib/utils/moleculeHighlights.ts @@ -1,6 +1,8 @@ export interface RdkitAtomJson { z?: number; impHs?: number; + chg?: number; + isotope?: number; } export interface RdkitBondJson { @@ -58,6 +60,21 @@ export function elementInventoryFromMolJson(parsed: unknown): Record { + const atoms = atomsFromMolJson(parsed); + if (!atoms || atoms.length === 0) return {}; + + const labels: Record = {}; + for (const [index, atom] of atoms.entries()) { + if (!isRecord(atom)) continue; + const rdkitAtom = atom as RdkitAtomJson; + if (elementSymbolForAtomicNumber(rdkitAtom.z) === 'C' && !rdkitAtom.chg && !rdkitAtom.isotope) { + labels[index] = 'C'; + } + } + return labels; +} + export function buildMoleculeHighlightPlan( parsed: unknown, elementColors: ElementColorMap, diff --git a/tests/unit/components/AtomMappingSummary.test.tsx b/tests/unit/components/AtomMappingSummary.test.tsx deleted file mode 100644 index 8dab9740..00000000 --- a/tests/unit/components/AtomMappingSummary.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render, fireEvent } from '@testing-library/react'; -import AtomMappingSummary from '@/components/ui/AtomMappingSummary'; - -const VALID_PAIRS = [ - 'cpd00001:O#1=cpd00009:O#2', - 'cpd00012:O#1=cpd00009:O#1', - 'cpd00012:O#2=cpd00009:O#2', - 'cpd00012:O#3=cpd00009:O#3', - 'cpd00012:O#4=cpd00009:O#3', -]; - -describe('AtomMappingSummary', () => { - it('renders nothing when entries is undefined', () => { - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); - - it('renders nothing when entries contains only malformed strings', () => { - const { container } = render( - , - ); - expect(container.firstChild).toBeNull(); - }); - - it('renders the summary count and all compound ids for the five valid pairs', () => { - const { container } = render(); - - expect(container.textContent).toContain('5 atom mappings across 3 compounds'); - expect(container.textContent).toContain('cpd00001'); - expect(container.textContent).toContain('cpd00009'); - expect(container.textContent).toContain('cpd00012'); - }); - - it('renders a success chip for confidence "clean"', () => { - const { container } = render(); - expect(container.textContent).toContain('clean'); - }); - - it('renders a warning chip for confidence "salvaged"', () => { - const { container } = render(); - expect(container.textContent).toContain('salvaged'); - }); - - it('still renders a chip for an unrecognised confidence value', () => { - const { container } = render( - , - ); - expect(container.textContent).toContain('mystery-value'); - }); - - it('hides the raw pair list until the toggle is clicked, then shows it', () => { - const { container, getByText } = render(); - - expect(container.textContent).not.toContain('cpd00001:O#1=cpd00009:O#2'); - - fireEvent.click(getByText('Show all mappings')); - - expect(container.textContent).toContain('O#1 = O#2'); - expect(container.textContent).toContain('O#4 = O#3'); - }); - - it('explains symmetry groups and labels multi-member sides as any of', () => { - const { container, getByText } = render( - , - ); - - expect(container.textContent).toContain('symmetry groups'); - expect(container.textContent).toContain( - 'A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined.', - ); - expect(container.textContent).toContain('1 of 1 mappings resolve to a symmetry-equivalent group'); - - fireEvent.click(getByText('Show all mappings')); - expect(container.textContent).toContain('any of O#1, O#2 = O#3'); - }); -}); diff --git a/tests/unit/components/MoleculeRenderer.test.tsx b/tests/unit/components/MoleculeRenderer.test.tsx index 2d41c7ba..6c427e4b 100644 --- a/tests/unit/components/MoleculeRenderer.test.tsx +++ b/tests/unit/components/MoleculeRenderer.test.tsx @@ -25,6 +25,22 @@ describe('MoleculeRenderer', () => { expect(mol.get_svg_with_highlights).not.toHaveBeenCalled(); }); + it('uses explicit carbon labels and preserves atom label colours when requested', async () => { + getMol.mockImplementationOnce(() => ({ + get_json: () => JSON.stringify({ molecules: [{ atoms: [{}, { z: 8 }, {}], bonds: [] }] }), + get_svg: vi.fn(), + get_svg_with_highlights: vi.fn(() => ""), + delete: vi.fn(), + })); + const { container } = render(); + await waitFor(() => expect(container.querySelector('[class="atom-0"]')?.getAttribute('fill')).toBe('#123456')); + expect(container.querySelector('[class="atom-2"]')?.getAttribute('fill')).toBe('#abcdef'); + const mol = getMol.mock.results.at(-1)?.value; + expect(JSON.parse(mol.get_svg_with_highlights.mock.calls[0][0])).toMatchObject({ + atomLabels: { 0: 'C', 2: 'C' }, + }); + }); + it('renders a stored SVG unmodified when no SMILES is available', () => { const fallbackSvg = ''; const { container } = render(); diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx index c130d555..e799c904 100644 --- a/tests/unit/components/ReactionStructureEquation.test.tsx +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -58,6 +58,13 @@ describe('ReactionStructureEquation', () => { expect(container.textContent).toContain('cpd00001'); }); + it('requests explicit labels for every rendered compound structure', async () => { + const { getByText } = renderEquation(); + await waitFor(() => expect(getByText('Water')).toBeTruthy()); + expect(rendererCalls.length).toBeGreaterThan(0); + expect(rendererCalls.every((call) => call.showAllAtomLabels === true)).toBe(true); + }); + it('uses one phosphorus colour on both compounds and discloses ambiguous mappings', async () => { const { container } = renderEquation({ atomMappingPairs: pairs }); await waitFor(() => expect(container.textContent).toContain('Atom mapping')); @@ -66,6 +73,12 @@ describe('ReactionStructureEquation', () => { expect(rendererCalls.some((call) => call.elementColors)).toBe(false); }); + it('does not render the removed atom-pair summary disclosure', async () => { + const { container, queryByText } = renderEquation({ atomMappingPairs: pairs }); + await waitFor(() => expect(container.textContent).toContain('Atom mapping')); + expect(queryByText(/Show all mappings|Show all .*atom|atom pairs/i)).toBeNull(); + }); + it('colours cpd00009 phosphorus separately from its oxygens after its graph arrives', async () => { vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([ ['cpd00009', { id: 'cpd00009', inchi: 'InChI=1S/H3O4P/c1-5(2,3)4', smiles: 'O=P([O-])([O-])O' }], diff --git a/tests/unit/utils/moleculeHighlights.test.ts b/tests/unit/utils/moleculeHighlights.test.ts index 5ea0380f..56091fa5 100644 --- a/tests/unit/utils/moleculeHighlights.test.ts +++ b/tests/unit/utils/moleculeHighlights.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { applyAtomLabelColors, applyBondColors, + buildExplicitAtomLabels, buildMoleculeHighlightPlan, elementInventoryFromMolJson, elementSymbolForAtomicNumber, @@ -38,6 +39,26 @@ describe('moleculeHighlights', () => { } }); + describe('buildExplicitAtomLabels', () => { + it('labels every carbon in a pure carbon chain', () => { + expect(buildExplicitAtomLabels({ molecules: [{ atoms: [{}, {}, {}] }] })).toEqual({ 0: 'C', 1: 'C', 2: 'C' }); + }); + + it('labels only carbon atoms in a mixed molecule', () => { + expect(buildExplicitAtomLabels({ molecules: [{ atoms: [{}, { z: 8 }, { z: 7 }, {}] }] })).toEqual({ 0: 'C', 3: 'C' }); + }); + + it('excludes charged and isotopic carbons', () => { + expect(buildExplicitAtomLabels({ molecules: [{ atoms: [{}, { chg: 1 }, { isotope: 13 }] }] })).toEqual({ 0: 'C' }); + }); + + it('returns no labels for unusable input', () => { + for (const value of [null, 'x', {}, { molecules: [] }, { molecules: [{ atoms: [] }] }]) { + expect(buildExplicitAtomLabels(value)).toEqual({}); + } + }); + }); + it('colours only bonds between atoms with the same colour', () => { const plan = buildMoleculeHighlightPlan({ molecules: [{ From 927a026181043e328851c890591c2b1154307281 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Tue, 25 Aug 2026 11:47:47 -0500 Subject: [PATCH 31/34] fix(ui): show reaction atom mapping only in SVGs --- components/ui/ReactionStructureEquation.tsx | 138 +++--------------- .../ReactionStructureEquation.test.tsx | 66 ++++----- 2 files changed, 51 insertions(+), 153 deletions(-) diff --git a/components/ui/ReactionStructureEquation.tsx b/components/ui/ReactionStructureEquation.tsx index 99064d38..367886be 100644 --- a/components/ui/ReactionStructureEquation.tsx +++ b/components/ui/ReactionStructureEquation.tsx @@ -5,19 +5,15 @@ import NextLink from 'next/link'; import { useCallback, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import Box from '@mui/material/Box'; -import Chip from '@mui/material/Chip'; import Skeleton from '@mui/material/Skeleton'; -import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { getCompoundsForReaction } from '@/lib/api/biochem'; import { getStructuresByIds, type CompoundStructure } from '@/lib/api/structures'; -import { heavyAtomCount, isParsableFormula, parseFormulaInventory } from '@/lib/utils/chemicalFormula'; +import { heavyAtomCount, isParsableFormula } from '@/lib/utils/chemicalFormula'; import type { AtomMappingPair } from '@/lib/utils/atomMapping'; import { - buildAtomMappingColorPlan, - type UnmappableReason, } from '@/lib/utils/atomMappingColors'; -import { buildAtomOrbitColorPlan, compoundColorResult, type CompoundColorResult, type MappingPrecision } from '@/lib/utils/atomOrbitColors'; +import { buildAtomOrbitColorPlan, compoundColorResult } from '@/lib/utils/atomOrbitColors'; import type { HeavyAtomGraph } from '@/lib/utils/inchiAtomOrder'; import type { AtomColors } from './MoleculeRenderer'; @@ -48,33 +44,7 @@ type DisplayData = { name?: string; smiles?: string; formula?: string; charge?: const EMPTY_PARSED: ParsedEquation = { reactants: [], products: [], arrow: '⇒' }; const EMPTY_MAP = new Map(); const EMPTY_STRUCTURES = new Map(); -const REASON_TEXT: Record = { - 'no-mapping': 'no mapping data', - 'element-mismatch': 'element mismatch between sides', - 'structure-unknown': 'structure unavailable', - 'partial-coverage': 'mapping covers only part of the structure', - 'counterpart-unresolved': 'no matching atoms found on the other side', -}; const compoundLinkStyle = { color: '#00838f', textDecoration: 'none', fontWeight: 600 }; -const PRECISION_LABELS: Record = { - 'exact-atom': 'Exact atom mapping', 'symmetry-orbit': 'Symmetry-equivalent atoms', 'element-block': 'Element-level mapping', unresolved: 'No atom mapping shown', -}; -const ORBIT_REASON_TEXT: Record = { - 'no-mapping': 'This participant has no mapping data.', 'no-structure': 'Structure data is unavailable, so no atom mapping is shown.', 'merged-groups': 'Mapping groups overlap, so no atom mapping is shown.', 'partial-coverage': 'The mapping covers only part of this structure, so no atom mapping is shown.', - 'no-inchi': 'No InChI is available to establish atom correspondence.', 'multi-component': 'The InChI has multiple components, so atom correspondence cannot be established.', 'unsupported-inchi': 'This InChI form cannot establish atom correspondence.', 'formula-parse-failed': 'The InChI formula could not be interpreted for atom correspondence.', 'connection-parse-failed': 'The InChI connection data could not establish atom correspondence.', 'atom-count-mismatch': 'The structure atom count does not match the InChI.', 'element-count-mismatch': 'The structure element counts do not match the InChI.', 'bond-count-mismatch': 'The structure bonds do not match the InChI.', 'too-large': 'This structure is too large for safe atom correspondence.', 'no-isomorphism': 'The structure cannot be matched to the InChI atom graph.', 'search-exhausted': 'Atom correspondence could not be established within the safe search limit.', -}; -function precisionExplanation(result: CompoundColorResult): string { - if (result.precision === 'exact-atom') return 'Each colour identifies the exact mapped atom.'; - if (result.precision === 'symmetry-orbit') return 'This colour marks a set of symmetry-equivalent atoms; the individual atom within that set is not distinguished by the data.'; - if (result.precision === 'element-block') return 'This colour is a claim at whole-element granularity, not an individual atom correspondence.'; - return ORBIT_REASON_TEXT[result.reason ?? ''] ?? 'Atom correspondence could not be established, so no atom mapping is shown.'; -} - -function joinCompoundIds(ids: readonly string[]): string { - if (ids.length <= 2) return ids.join(' and '); - return `${ids.slice(0, -1).join(', ')} and ${ids.at(-1)}`; -} - function parseEquation(equation: string): ParsedEquation { let arrow = '⇒'; let lhs = equation; @@ -108,12 +78,6 @@ function directionText(arrow: string): string { return 'reaction proceeds left to right'; } -function confidenceColor(value: string): 'success' | 'warning' | 'default' { - if (value === 'clean') return 'success'; - if (value === 'salvaged') return 'warning'; - return 'default'; -} - interface CompoundColumnProps { token: CompoundToken; data?: DisplayData; @@ -121,15 +85,12 @@ interface CompoundColumnProps { atomColors?: AtomColors; bondColors?: Record; showAllAtomLabels?: boolean; - mappingDescription?: string; - mappingControls?: Readonly>; - highlightedGroup?: string; onInventory: (inventory: Inventory) => void; onGraph: (graph: HeavyAtomGraph) => void; - isLoading: boolean; precisionResult?: CompoundColorResult; + isLoading: boolean; } -function CompoundColumn({ token, data, structure, atomColors, bondColors, showAllAtomLabels, mappingDescription, mappingControls, highlightedGroup, onInventory, onGraph, isLoading, precisionResult }: CompoundColumnProps) { +function CompoundColumn({ token, data, structure, atomColors, bondColors, showAllAtomLabels, onInventory, onGraph, isLoading }: CompoundColumnProps) { const smiles = data?.smiles ?? structure?.smiles; const drawStructure = Boolean(structure?.svg) || (Boolean(smiles) && (!data?.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1)); const label = data?.name || token.id; @@ -137,9 +98,6 @@ function CompoundColumn({ token, data, structure, atomColors, bondColors, showAl const accessibleDescription = [ data?.formula && `Formula ${data.formula}`, formatCharge(data?.charge) && `charge ${formatCharge(data?.charge)}`, - precisionResult && PRECISION_LABELS[precisionResult.precision], - precisionResult && precisionExplanation(precisionResult), - mappingDescription, ].filter(Boolean).join(' · '); const contents = isLoading ? ( @@ -162,20 +120,15 @@ function CompoundColumn({ token, data, structure, atomColors, bondColors, showAl {label}{formatCharge(data?.charge) && {formatCharge(data?.charge)}} ); - const isMember = Boolean(highlightedGroup && Object.values(mappingControls ?? {}).some((control) => control.groupId === highlightedGroup)); - const isDimmed = Boolean(highlightedGroup && !isMember); - const highlightColor = isMember ? Object.values(mappingControls ?? {}).find((control) => control.groupId === highlightedGroup)?.color : undefined; return ( - - {token.stoich && {token.stoich}} + + {token.stoich && {token.stoich}} - - - {contents} - - + + {contents} + {isLoading ? : - {label} + {label} } {metadata} @@ -183,30 +136,26 @@ function CompoundColumn({ token, data, structure, atomColors, bondColors, showAl ); } -function EquationSide({ tokens, displayMap, structures, atomMapping, useOrbitColors, plan, orbitPlan, callbacks, graphCallbacks, isLoading, highlightedGroup }: { +function EquationSide({ tokens, displayMap, structures, atomMapping, useOrbitColors, orbitPlan, callbacks, graphCallbacks, isLoading }: { tokens: CompoundToken[]; displayMap: Map; structures: Map; - atomMapping?: ReactionAtomMapping; useOrbitColors: boolean; plan: ReturnType; orbitPlan: ReturnType; + atomMapping?: ReactionAtomMapping; useOrbitColors: boolean; orbitPlan: ReturnType; callbacks: Readonly void>>; graphCallbacks: Readonly void>>; - isLoading: boolean; highlightedGroup?: string; + isLoading: boolean; }) { return {tokens.map((token, index) => { const orbitColors = compoundColorResult(orbitPlan, token.id); - const tokenBlocks = plan.blocks.filter((block) => block.colorable && block.compoundId === token.id); - const mappingControls = Object.fromEntries(orbitPlan.groups.filter((group) => group.compoundIds.includes(token.id)).flatMap((group) => group.elements.map((element) => [element, { groupId: group.groupId, color: group.color }]))); - const precisionResult = orbitPlan.compounds[token.id]; - const descriptions = tokenBlocks.map((block) => `${block.element} mapped to ${block.counterpartCompoundIds.join(', ')}`); return + onInventory={callbacks[token.id]} onGraph={graphCallbacks[token.id]} isLoading={isLoading} /> {index < tokens.length - 1 && } ; })} ; } -export default function ReactionStructureEquation({ equation, reversibility, atomMapping, atomMappingPairs, atomMappingConfidence, atomMappingHasSymmetryGroups }: ReactionStructureEquationProps) { +export default function ReactionStructureEquation({ equation, reversibility, atomMapping, atomMappingPairs }: ReactionStructureEquationProps) { const parsed = useMemo(() => equation ? parseEquation(equation) : EMPTY_PARSED, [equation]); const arrow = useMemo(() => { if (reversibility === '=' || reversibility === '<=>') return '⇌'; @@ -232,11 +181,7 @@ export default function ReactionStructureEquation({ equation, reversibility, ato name: compound.name, smiles: compound.smiles, formula: compound.formula, charge: compound.charge, }])); }, [compoundMap]); - const [inventories, setInventories] = useState>({}); - const saveInventory = useCallback((compoundId: string, inventory: Inventory) => { - setInventories((previous) => JSON.stringify(previous[compoundId] ?? {}) === JSON.stringify(inventory) - ? previous : { ...previous, [compoundId]: inventory }); - }, []); + const saveInventory = useCallback((_compoundId: string, _inventory: Inventory) => { void _compoundId; void _inventory; }, []); const inventoryCallbacks = useMemo(() => Object.fromEntries(uniqueCompoundIds.map((id) => [id, (inventory: Inventory) => saveInventory(id, inventory)])), [uniqueCompoundIds, saveInventory]); const [graphs, setGraphs] = useState>({}); const saveGraph = useCallback((compoundId: string, graph: HeavyAtomGraph) => { @@ -249,63 +194,20 @@ export default function ReactionStructureEquation({ equation, reversibility, ato const graphCallbacks = useMemo(() => Object.fromEntries(uniqueCompoundIds.map((id) => [id, (graph: HeavyAtomGraph) => saveGraph(id, graph)])), [uniqueCompoundIds, saveGraph]); const pairs = useMemo(() => atomMappingPairs ?? [], [atomMappingPairs]); const useOrbitColors = pairs.length > 0; - const inventoriesForPlan = useMemo(() => { - const seeded = Object.fromEntries(Array.from(displayMap.entries()).flatMap(([id, data]) => { - const drawStructure = Boolean(data.smiles) && (!data.formula || !isParsableFormula(data.formula) || heavyAtomCount(data.formula) >= 1); - // structureAtomCount is the colour-safety gate; formula/SMILES disagreement must not assert coverage before RDKit reports it. - const inventory = drawStructure ? {} : parseFormulaInventory(data.formula); - return Object.keys(inventory).length > 0 ? [[id, inventory]] : []; - })); - return { ...seeded, ...inventories }; - }, [displayMap, inventories]); - const plan = useMemo(() => buildAtomMappingColorPlan(pairs, inventoriesForPlan), [pairs, inventoriesForPlan]); const orbitPlan = useMemo(() => buildAtomOrbitColorPlan(pairs, uniqueCompoundIds.map((compoundId) => ({ compoundId, inchi: structures.get(compoundId)?.inchi, graph: graphs[compoundId], }))), [pairs, structures, graphs, uniqueCompoundIds]); - const [selectedGroup, setSelectedGroup] = useState(); - const [hoveredGroup, setHoveredGroup] = useState(); - const highlightedGroup = selectedGroup ?? hoveredGroup; - const selectGroup = useCallback((groupId: string) => setSelectedGroup((previous) => previous === groupId ? undefined : groupId), []); - const reasons = useMemo(() => Array.from(new Set(plan.unmappable.map((block) => block.reason).filter((reason): reason is UnmappableReason => Boolean(reason)))).map((reason) => REASON_TEXT[reason]), [plan]); if (!equation) return null; - return { if (event.key === 'Escape') setSelectedGroup(undefined); }}> + return - + - + {error && Compound details could not be loaded.} - {useOrbitColors && !isLoading && !structuresLoading && orbitPlan.groups.length > 0 && - {(plan.colorableCount > 0 || atomMappingConfidence || atomMappingHasSymmetryGroups) && - {orbitPlan.groups.length > 0 && Atom mapping} - {atomMappingConfidence && } - } - - {orbitPlan.groups.map((entry) => { - const description = `${entry.elements.join(', ')}: ${joinCompoundIds(entry.compoundIds)}${entry.hasSymmetryGroup ? ' — grouped; individual atom pairing is not determined by the data' : ''}`; - return - selectGroup(entry.groupId)} onMouseEnter={() => setHoveredGroup(entry.groupId)} onMouseLeave={() => setHoveredGroup(undefined)} onFocus={() => setHoveredGroup(entry.groupId)} onBlur={() => setHoveredGroup(undefined)} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, border: 0, bgcolor: 'transparent', p: 0, cursor: 'pointer', textAlign: 'left' }}> - - ; - })} - - - Mapping details - {atomMappingHasSymmetryGroups && A grouped mapping resolves to any one member of a set of symmetry-equivalent atoms, so the specific atom is not determined.} - {(() => { - const summary = (['exact-atom', 'symmetry-orbit', 'element-block', 'unresolved'] as MappingPrecision[]) - .filter((precision) => orbitPlan.precisionSummary[precision] > 0) - .map((precision) => `${orbitPlan.precisionSummary[precision]} ${PRECISION_LABELS[precision].toLowerCase()}`) - .join('; '); - return summary && Precision: {summary}.; - })()} - {reasons.length > 0 && Some atoms could not be unambiguously mapped and therefore are not coloured: {reasons.join('; ')}.} - - } + {structureError && Structure data could not be loaded, so atom-level mapping precision is unavailable and any colours shown are element-level at best.} ; } diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx index e799c904..4bb89397 100644 --- a/tests/unit/components/ReactionStructureEquation.test.tsx +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -65,20 +65,15 @@ describe('ReactionStructureEquation', () => { expect(rendererCalls.every((call) => call.showAllAtomLabels === true)).toBe(true); }); - it('uses one phosphorus colour on both compounds and discloses ambiguous mappings', async () => { + it('colors mapped atoms without rendering a mapping text section', async () => { const { container } = renderEquation({ atomMappingPairs: pairs }); - await waitFor(() => expect(container.textContent).toContain('Atom mapping')); - expect(container.querySelectorAll('[aria-label="Atom mapping legend"] li').length).toBeGreaterThan(0); - expect(container.querySelector('[aria-label="Atom mapping legend"] button[aria-label*="individual atom pairing"]')).toBeTruthy(); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00009"]')).toBeTruthy()); + expect(container.textContent).not.toContain('Atom mapping'); + expect(container.textContent).not.toContain('Mapping details'); + expect(container.querySelector('[aria-label="Atom mapping legend"]')).toBeNull(); expect(rendererCalls.some((call) => call.elementColors)).toBe(false); }); - it('does not render the removed atom-pair summary disclosure', async () => { - const { container, queryByText } = renderEquation({ atomMappingPairs: pairs }); - await waitFor(() => expect(container.textContent).toContain('Atom mapping')); - expect(queryByText(/Show all mappings|Show all .*atom|atom pairs/i)).toBeNull(); - }); - it('colours cpd00009 phosphorus separately from its oxygens after its graph arrives', async () => { vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([ ['cpd00009', { id: 'cpd00009', inchi: 'InChI=1S/H3O4P/c1-5(2,3)4', smiles: 'O=P([O-])([O-])O' }], @@ -140,8 +135,9 @@ describe('ReactionStructureEquation', () => { it('settles inventory effects without a maximum-depth update error', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); const { container } = renderEquation({ atomMappingPairs: pairs }); - await waitFor(() => expect(container.textContent).toContain('Atom mapping')); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00009"]')).toBeTruthy()); expect(error.mock.calls.flat().join(' ')).not.toContain('Maximum update depth'); + expect(container.textContent).not.toContain('Mapping details'); error.mockRestore(); }); @@ -300,7 +296,7 @@ describe('ReactionStructureEquation', () => { 'cpd00742:N#2=cpd00013:N#1', 'cpd00742:O#1=cpd00011:(O#1;O#2)', ]); - const { container, getByTestId, getByText, getAllByText, queryByTestId } = renderEquation({ + const { container, getByTestId, getAllByText, queryByTestId } = renderEquation({ equation: '(1) cpd00001[c] + (1) cpd00742[c] => (2) cpd00011[c] + (1) cpd00013[c] + (1) cpd00067[c]', atomMappingPairs: rxnPairs, atomMappingConfidence: 'clean', atomMappingHasSymmetryGroups: true, }); @@ -311,8 +307,10 @@ describe('ReactionStructureEquation', () => { expect(container.textContent).toContain(id); expect(container.querySelector(`a[href="/biochem/compounds/${id}"]`)).toBeTruthy(); } - expect(getByText(/O · cpd00001, cpd00011, cpd00742 †/)).toBeTruthy(); - expect(container.querySelector('[aria-label="Atom mapping legend"] button[aria-label*="individual atom pairing"]')).toBeTruthy(); + expect(container.textContent).not.toContain('Atom mapping'); + expect(container.querySelector('[aria-label="Atom mapping legend"]')).toBeNull(); + expect(container.textContent).not.toContain('Mapping details'); + await waitFor(() => expect(rendererCalls.some((call) => call.compoundId === 'cpd00011' && call.onGraph)).toBe(true)); const water = getByTestId('structure-cpd00001'); const allophanate = getByTestId('structure-cpd00742'); @@ -320,7 +318,7 @@ describe('ReactionStructureEquation', () => { expect(getAllByText('H+', { selector: 'a p' }).length).toBeGreaterThan(0); }); - it('lets a legend group highlight matching tokens and toggles it off', async () => { + it.skip('legacy legend group interaction (mapping text section removed)', async () => { const rxnPairs = parseAtomMappings([ 'cpd00001:O#1=cpd00011:(O#1;O#2)', 'cpd00742:(O#2;O#3)=cpd00011:(O#1;O#2)', 'cpd00742:C#1=cpd00011:C#1', 'cpd00742:C#2=cpd00011:C#1', @@ -342,7 +340,7 @@ describe('ReactionStructureEquation', () => { expect(container.querySelectorAll('[data-mapping-dimmed="true"]')).toHaveLength(0); }); - it('highlights on focus and Escape clears a sticky legend selection', async () => { + it.skip('legacy legend keyboard interaction (mapping text section removed)', async () => { const rxnPairs = parseAtomMappings(['cpd00742:C#1=cpd00011:C#1', 'cpd00742:C#2=cpd00011:C#1']); vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], ['cpd00011', compound({ name: 'CO2', smiles: 'O=C=O', formula: 'CO2', charge: 0 })]])); const { container, getByRole } = renderEquation({ equation: 'cpd00742[c] => cpd00011[c]', atomMappingPairs: rxnPairs }); @@ -355,7 +353,7 @@ describe('ReactionStructureEquation', () => { expect(carbon.getAttribute('aria-pressed')).toBe('false'); }); - it('uses the legend as the only operable mapping-group control', async () => { + it.skip('legacy legend controls (mapping text section removed)', async () => { const rxnPairs = parseAtomMappings(['cpd00742:C#1=cpd00011:C#1', 'cpd00742:C#2=cpd00011:C#1']); vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], ['cpd00011', compound({ name: 'CO2', smiles: 'O=C=O', formula: 'CO2', charge: 0 })]])); const { getByRole, queryByRole } = renderEquation({ equation: 'cpd00742[c] => cpd00011[c]', atomMappingPairs: rxnPairs }); @@ -365,7 +363,7 @@ describe('ReactionStructureEquation', () => { expect(legend.getAttribute('aria-pressed')).toBe('true'); }); - it('keeps the selected group after pointer hover leaves another control', async () => { + it.skip('legacy selected-group hover interaction (mapping text section removed)', async () => { const rxnPairs = parseAtomMappings(['cpd00742:C#1=cpd00011:C#1', 'cpd00742:C#2=cpd00011:C#1']); vi.mocked(getCompoundsForReaction).mockResolvedValueOnce(new Map([['cpd00742', compound({ name: 'Allophanate', smiles: 'NC(=O)NC(=O)[O-]', formula: 'C2H3N2O3', charge: -1 })], ['cpd00011', compound({ name: 'CO2', smiles: 'O=C=O', formula: 'CO2', charge: 0 })]])); const { container, getByRole } = renderEquation({ equation: 'cpd00742[c] => cpd00011[c]', atomMappingPairs: rxnPairs }); @@ -404,32 +402,30 @@ describe('ReactionStructureEquation', () => { } } - it('keeps precision labels and explanations in accessible descriptions and Mapping details', async () => { + it('keeps accessible compound descriptions while omitting mapping prose', async () => { vi.mocked(getStructuresByIds).mockResolvedValueOnce(new Map([ ['cpd00001', { id: 'cpd00001', inchi: 'InChI=1S/H2O/h1H2' }], ['cpd00009', { id: 'cpd00009', inchi: 'InChI=1S/H3O4P/c1-5(2,3)4/h(H3,1,2,3,4)/p-2' }], ])); - const { getByText, queryByRole } = renderEquation({ equation: 'cpd00001[c] => cpd00009[c]', atomMappingPairs: parseAtomMappings(['cpd00001:O#1=cpd00009:O#1']) }); + const { container } = renderEquation({ equation: 'cpd00001[c] => cpd00009[c]', atomMappingPairs: parseAtomMappings(['cpd00001:O#1=cpd00009:O#1']) }); await provideGraphs(); - await waitFor(() => expect(rendererCalls.filter((call) => call.compoundId === 'cpd00001').at(-1)?.alt).toContain('Exact atom mapping')); - expect(rendererCalls.filter((call) => call.compoundId === 'cpd00001').at(-1)?.alt).toContain('Each colour identifies the exact mapped atom.'); - expect(queryByRole('button', { name: /mapping$/ })).toBeNull(); - expect(getByText('Mapping details').closest('details')?.textContent).toContain('Precision:'); + await waitFor(() => expect(rendererCalls.filter((call) => call.compoundId === 'cpd00001').at(-1)?.alt).toContain('Formula H2O')); + expect(rendererCalls.filter((call) => call.compoundId === 'cpd00001').at(-1)?.alt).not.toContain('Exact atom mapping'); + expect(container.textContent).not.toContain('Mapping details'); + expect(container.querySelector('[aria-label="Atom mapping legend"]')).toBeNull(); }); - it('colours highlighted participant names without outlining token boxes', async () => { - const { container, getByRole, getByText } = renderEquation({ atomMappingPairs: pairs }); - const phosphorus = await waitFor(() => getByRole('button', { name: /^P:/ })); - fireEvent.click(phosphorus); - const token = container.querySelector('[data-mapping-token="cpd00009"]') as HTMLElement; - expect(token.style.outline).toBe(''); - expect((getByText('Phosphate', { selector: 'a p' }) as HTMLElement).style.color).not.toBe(''); + it('keeps compound participants visible without mapping controls', async () => { + const { container } = renderEquation({ atomMappingPairs: pairs }); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00009"]')).toBeTruthy()); + expect(container.querySelector('[data-mapping-token="cpd00009"]')).toBeTruthy(); + expect(container.querySelector('[aria-label="Atom mapping legend"]')).toBeNull(); }); - it('renders Mapping details with the precision summary', async () => { - const { getByText } = renderEquation({ atomMappingPairs: pairs }); - const details = await waitFor(() => getByText('Mapping details').closest('details')); - expect(details?.textContent).toContain('Precision:'); + it('does not render Mapping details', async () => { + const { container } = renderEquation({ atomMappingPairs: pairs }); + await waitFor(() => expect(container.querySelector('[data-testid="structure-cpd00009"]')).toBeTruthy()); + expect(container.textContent).not.toContain('Mapping details'); }); it('keeps participants visible and reports a structures-query error while preserving honest element-level colours', async () => { From 46fd8b218951eb43bb23fcf6dc2024e3490d6850 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Tue, 25 Aug 2026 12:18:17 -0500 Subject: [PATCH 32/34] fix(a11y): use a color-blind-safe atom mapping palette Sam Seaver reported the SVG atom-mapping colors are unusable for color-blind users. The previous 12-color palette mixed Okabe-Ito with tab10/NEJM colors, which collapse under red-green color vision deficiency: brown #8C564B and green #20854E differ by only dE 4.2 under deuteranopia, and #E69F00 / #56B4E9 sat at 2.25:1 and 2.31:1 contrast against the white SVG canvas. Replace it with 8 colors built on the four Okabe-Ito members that clear WCAG 2.1 SC 1.4.11 (3:1) on white, plus four optimized extensions. Worst-case pairwise separation under protanopia/deuteranopia rises from dE 4.2 to 19.0, every color clears 3:1 on white, and all stay dE >= 59 from the black RDKit uses for unmapped atoms. Palette values are the only executable change; group assignment stays MAPPING_PALETTE[index % length], so ordering, atom correspondence, RDKit indices, and API contracts are untouched, and no mapping legend, rows, or details UI returns. Add tests/unit/utils/mappingPaletteSafety.test.ts, which derives WCAG contrast, CIE-Lab dE, and Vienot 1999 dichromacy simulation from the hex values rather than snapshotting them, and proves the checks have teeth by asserting the retired palette fails them. Known limitation: vermillion and reddish purple remain close under tritanopia (~0.01% prevalence); this is inherent to Okabe-Ito and is pinned by an explicit test. --- lib/utils/atomMappingColors.ts | 50 +++- .../ReactionStructureEquation.test.tsx | 5 + tests/unit/utils/atomMappingColors.test.ts | 17 +- tests/unit/utils/mappingPaletteSafety.test.ts | 215 ++++++++++++++++++ 4 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 tests/unit/utils/mappingPaletteSafety.test.ts diff --git a/lib/utils/atomMappingColors.ts b/lib/utils/atomMappingColors.ts index b5ace27f..e1a814c1 100644 --- a/lib/utils/atomMappingColors.ts +++ b/lib/utils/atomMappingColors.ts @@ -55,9 +55,55 @@ export interface AtomMappingColorPlan { readonly totalCount: number; } +/** + * Colour-vision-deficiency-safe categorical palette for atom mapping groups. + * + * Colours are assigned by group order (`MAPPING_PALETTE[index % length]`) and + * are consumed in three SVG roles by `components/ui/MoleculeRenderer.tsx`: + * RDKit highlight halo fills, atom-label `fill:` text, and bond `stroke:`. + * The molecule canvas is always white (`lib/theme.ts` sets both `background.default` + * and `background.paper` to `#ffffff`; there is no dark mode) and RDKit draws + * unmapped atoms and bonds in black, so every entry must read against white + * *and* stay clearly distinct from black. + * + * Design constraints, all asserted by `tests/unit/utils/mappingPaletteSafety.test.ts`: + * + * 1. Entries 0-3 are the four Okabe-Ito (Okabe & Ito 2008; Wong, *Nature Methods* + * 8:441, 2011) colours that clear the contrast bar unmodified. The remaining + * Okabe-Ito members are deliberately excluded: orange `#E69F00` (2.25:1) and + * sky blue `#56B4E9` (2.31:1) fail against white, and darkening them to pass + * collapses the set's own separation (darkened orange approaches vermillion, + * darkened sky blue approaches blue), which defeats the purpose. + * 2. Entries 4-7 extend the set under the same rules rather than borrowing from + * a palette that was never CVD-checked. + * 3. Contrast against white is >= 3:1 for every entry (WCAG 2.1 SC 1.4.11 + * Non-text Contrast, the applicable bar for bond strokes and atom glyphs as + * graphical objects). + * 4. Under simulated protanopia and deuteranopia — the common red-green + * deficiencies, ~8% of males — the minimum CIE-Lab dE76 between any two + * entries is 19.0. No pair is separated by red-versus-green hue alone. + * 5. Every entry stays far from black (min dE76 59.4) so mapped atoms never read + * as unmapped. + * + * Deliberately eight colours, not more: a longer list only helps if its members + * stay distinguishable. The previous twelve-colour set collapsed to dE76 4.22 + * under deuteranopia (`#8C564B` brown vs `#20854E` green), i.e. it was ambiguous + * for every reaction; eight true colours are ambiguous only once a reaction + * exceeds eight mapping groups and the palette wraps. + * + * Known limitation: under tritanopia (~0.01% prevalence, both sexes) vermillion + * and reddish purple converge (dE76 0.96). That is inherent to Okabe-Ito itself + * and is accepted here in favour of red-green separation. + */ export const MAPPING_PALETTE: readonly string[] = [ - '#0072B2', '#D55E00', '#009E73', '#CC79A7', '#E69F00', '#56B4E9', '#8C564B', - '#7F3FBF', '#BC3C29', '#20854E', '#6F99AD', '#EE4C97', + '#0072B2', // blue + '#D55E00', // vermillion + '#009E73', // bluish green + '#CC79A7', // reddish purple + '#FA3C5A', // rose red + '#0A5A14', // deep green + '#960A82', // magenta + '#0A5AE6', // indigo blue ]; interface AccumulatedBlock { diff --git a/tests/unit/components/ReactionStructureEquation.test.tsx b/tests/unit/components/ReactionStructureEquation.test.tsx index 4bb89397..98d10ffa 100644 --- a/tests/unit/components/ReactionStructureEquation.test.tsx +++ b/tests/unit/components/ReactionStructureEquation.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { parseAtomMappings } from '@/lib/utils/atomMapping'; +import { MAPPING_PALETTE } from '@/lib/utils/atomMappingColors'; import { getCompoundsForReaction, type Compound } from '@/lib/api/biochem'; import { getStructuresByIds } from '@/lib/api/structures'; import ReactionStructureEquation from '@/components/ui/ReactionStructureEquation'; @@ -88,6 +89,10 @@ describe('ReactionStructureEquation', () => { expect(colors[1]).not.toBe(colors[2]); expect(colors[1]).not.toBe(colors[3]); expect(colors[1]).not.toBe(colors[4]); + // Colours reaching the SVG renderer come from the CVD-safe palette. + const applied = Object.values(colors); + expect(applied.length).toBeGreaterThan(0); + for (const color of applied) expect(MAPPING_PALETTE).toContain(color); }); }); diff --git a/tests/unit/utils/atomMappingColors.test.ts b/tests/unit/utils/atomMappingColors.test.ts index ccebe828..4da88d72 100644 --- a/tests/unit/utils/atomMappingColors.test.ts +++ b/tests/unit/utils/atomMappingColors.test.ts @@ -105,19 +105,22 @@ describe('buildAtomMappingColorPlan', () => { expect(blockAssignment(buildAtomMappingColorPlan(input, inventories), 'cpd00001', 'O').mappedIndexCount).toBe(1); }); - it('cycles the palette after twelve sorted groups', () => { - const entries = Array.from({ length: 13 }, (_, index) => + it('cycles the palette once the sorted groups outnumber it', () => { + const count = MAPPING_PALETTE.length + 1; + const entries = Array.from({ length: count }, (_, index) => `cpd${String(index + 1).padStart(5, '0')}:O#1=cpd${String(index + 101).padStart(5, '0')}:O#1`, ); - const inventories = Object.fromEntries(Array.from({ length: 13 }, (_, index) => [ + const inventories = Object.fromEntries(Array.from({ length: count }, (_, index) => [ `cpd${String(index + 1).padStart(5, '0')}`, { O: 1 }, - ]).concat(Array.from({ length: 13 }, (_, index) => [ + ]).concat(Array.from({ length: count }, (_, index) => [ `cpd${String(index + 101).padStart(5, '0')}`, { O: 1 }, ]))); const plan = buildAtomMappingColorPlan(pairs(entries), inventories); - expect(plan.legend).toHaveLength(13); - expect(plan.legend[12].color).toBe(MAPPING_PALETTE[0]); - expect(new Set(plan.legend.map((entry) => entry.groupId)).size).toBe(13); + expect(plan.legend).toHaveLength(count); + expect(plan.legend.map((entry) => entry.color)).toEqual( + Array.from({ length: count }, (_, index) => MAPPING_PALETTE[index % MAPPING_PALETTE.length]), + ); + expect(new Set(plan.legend.map((entry) => entry.groupId)).size).toBe(count); }); it('returns only colourable element colours and an empty object for unknown compounds', () => { diff --git a/tests/unit/utils/mappingPaletteSafety.test.ts b/tests/unit/utils/mappingPaletteSafety.test.ts new file mode 100644 index 00000000..b436f93f --- /dev/null +++ b/tests/unit/utils/mappingPaletteSafety.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest'; +import { MAPPING_PALETTE } from '@/lib/utils/atomMappingColors'; +import { buildAtomOrbitColorPlan } from '@/lib/utils/atomOrbitColors'; +import type { AtomMappingPair } from '@/lib/utils/atomMapping'; + +/** + * Colour-science checks for `MAPPING_PALETTE`. + * + * These assert measurable properties computed from the palette values, not the + * values themselves, so the palette can be retuned without editing expectations + * while a regression that reintroduces confusable colours still fails. + */ + +const WHITE = '#FFFFFF'; +const BLACK = '#000000'; + +/** Molecule SVGs render on `#ffffff` (`lib/theme.ts` background.default/paper). */ +const CANVAS = WHITE; + +/** WCAG 2.1 SC 1.4.11 Non-text Contrast: graphical objects need 3:1. */ +const MIN_CONTRAST_ON_CANVAS = 3; +/** RDKit draws unmapped atoms/bonds in black; mapped colours must not read as black. */ +const MIN_DELTA_E_FROM_BLACK = 45; +/** Every pair must stay apart for normal trichromats. */ +const MIN_DELTA_E_NORMAL = 25; +/** ...and under the common red-green deficiencies. */ +const MIN_DELTA_E_CVD = 15; + +type Rgb = readonly [number, number, number]; + +const hexToRgb = (hex: string): Rgb => { + const h = hex.replace('#', ''); + return [ + Number.parseInt(h.slice(0, 2), 16), + Number.parseInt(h.slice(2, 4), 16), + Number.parseInt(h.slice(4, 6), 16), + ]; +}; + +const toLinear = (channel: number): number => { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +}; + +const encodeSrgb = (linear: number): number => { + const c = Math.min(1, Math.max(0, linear)); + return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; +}; + +const toHex = (linear: Rgb): string => + `#${linear.map((c) => Math.round(encodeSrgb(c) * 255).toString(16).padStart(2, '0').toUpperCase()).join('')}`; + +const relativeLuminance = (hex: string): number => { + const [r, g, b] = hexToRgb(hex).map(toLinear) as unknown as Rgb; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +/** WCAG contrast ratio, 1:1 to 21:1. */ +const contrastRatio = (a: string, b: string): number => { + const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +}; + +type Deficiency = 'protan' | 'deutan' | 'tritan'; + +/** + * Viénot, Brettel & Mollon (1999) linear dichromacy simulation via + * Hunt-Pointer-Estévez LMS. Standard model for protanopia/deuteranopia/tritanopia. + */ +const simulate = (hex: string, kind: Deficiency): string => { + const [r, g, b] = hexToRgb(hex).map(toLinear) as unknown as Rgb; + let L = 17.8824 * r + 43.5161 * g + 4.11935 * b; + let M = 3.45565 * r + 27.1554 * g + 3.86714 * b; + let S = 0.0299566 * r + 0.184309 * g + 1.46709 * b; + if (kind === 'protan') L = 2.02344 * M - 2.52581 * S; + else if (kind === 'deutan') M = 0.494207 * L + 1.24827 * S; + else S = -0.395913 * L + 0.801109 * M; + return toHex([ + 0.0809444479 * L - 0.130504409 * M + 0.116721066 * S, + -0.0102485335 * L + 0.0540193266 * M - 0.113614708 * S, + -0.000365296938 * L - 0.00412161469 * M + 0.693511405 * S, + ]); +}; + +const toLab = (hex: string): Rgb => { + const [r, g, b] = hexToRgb(hex).map(toLinear) as unknown as Rgb; + const x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047; + const y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + const z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883; + const f = (t: number): number => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); + const [fx, fy, fz] = [f(x), f(y), f(z)]; + return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; +}; + +/** CIE76 colour difference in CIE-Lab. */ +const deltaE = (a: string, b: string): number => { + const [la, aa, ba] = toLab(a); + const [lb, ab, bb] = toLab(b); + return Math.hypot(la - lb, aa - ab, ba - bb); +}; + +const pairs = (items: readonly T[]): [T, T][] => + items.flatMap((left, i) => items.slice(i + 1).map((right): [T, T] => [left, right])); + +const worstPair = (palette: readonly string[], kind: Deficiency | 'normal'): { delta: number; pair: [string, string] } => + pairs(palette) + .map(([a, b]) => ({ + delta: kind === 'normal' ? deltaE(a, b) : deltaE(simulate(a, kind), simulate(b, kind)), + pair: [a, b] as [string, string], + })) + .reduce((worst, candidate) => (candidate.delta < worst.delta ? candidate : worst)); + +describe('colour maths used by these checks', () => { + // Guards the assertions below: if this helper drifts, the palette checks are meaningless. + it('reproduces known reference values', () => { + expect(contrastRatio(BLACK, WHITE)).toBeCloseTo(21, 5); + expect(contrastRatio(WHITE, WHITE)).toBeCloseTo(1, 5); + expect(toLab(WHITE)[0]).toBeCloseTo(100, 3); + expect(toLab(BLACK)[0]).toBeCloseTo(0, 3); + expect(deltaE('#0072B2', '#0072B2')).toBe(0); + // Deuteranopia collapses pure red against pure green; normal vision does not. + const normal = deltaE('#FF0000', '#00FF00'); + const deutan = deltaE(simulate('#FF0000', 'deutan'), simulate('#00FF00', 'deutan')); + expect(normal).toBeGreaterThan(100); + expect(deutan / normal).toBeLessThan(0.35); + }); + + it('flags the previously shipped palette that motivated this change', () => { + // The old 12-colour set: brown #8C564B and green #20854E were indistinguishable + // under deuteranopia. Proves these thresholds can actually fail a bad palette. + const legacy = [ + '#0072B2', '#D55E00', '#009E73', '#CC79A7', '#E69F00', '#56B4E9', + '#8C564B', '#7F3FBF', '#BC3C29', '#20854E', '#6F99AD', '#EE4C97', + ]; + expect(worstPair(legacy, 'deutan').delta).toBeLessThan(MIN_DELTA_E_CVD); + expect(Math.min(...legacy.map((c) => contrastRatio(c, CANVAS)))).toBeLessThan(MIN_CONTRAST_ON_CANVAS); + }); +}); + +describe('MAPPING_PALETTE', () => { + it('is a non-trivial set of unique, well-formed colours', () => { + expect(MAPPING_PALETTE.length).toBeGreaterThanOrEqual(8); + expect(new Set(MAPPING_PALETTE).size).toBe(MAPPING_PALETTE.length); + for (const color of MAPPING_PALETTE) expect(color).toMatch(/^#[0-9A-F]{6}$/); + }); + + it('opens with the Okabe-Ito colours that clear the contrast bar unmodified', () => { + expect(MAPPING_PALETTE.slice(0, 4)).toEqual(['#0072B2', '#D55E00', '#009E73', '#CC79A7']); + }); + + it('drops the low-contrast and CVD-confusable entries of the previous palette', () => { + for (const retired of ['#E69F00', '#56B4E9', '#8C564B', '#20854E', '#6F99AD']) { + expect(MAPPING_PALETTE).not.toContain(retired); + } + }); + + it('meets WCAG non-text contrast against the white molecule canvas', () => { + for (const color of MAPPING_PALETTE) { + expect(contrastRatio(color, CANVAS)).toBeGreaterThanOrEqual(MIN_CONTRAST_ON_CANVAS); + } + }); + + it('stays clearly distinct from the black used for unmapped atoms and bonds', () => { + for (const color of MAPPING_PALETTE) { + expect(deltaE(color, BLACK)).toBeGreaterThanOrEqual(MIN_DELTA_E_FROM_BLACK); + } + }); + + it('separates every pair for normal colour vision', () => { + expect(worstPair(MAPPING_PALETTE, 'normal').delta).toBeGreaterThanOrEqual(MIN_DELTA_E_NORMAL); + }); + + // No pair may rely on a red-versus-green distinction: each must survive both + // simulations, which is exactly what a red/green-only pair fails to do. + it.each(['protan', 'deutan'] as const)('separates every pair under simulated %s-opia', (kind) => { + const { delta, pair } = worstPair(MAPPING_PALETTE, kind); + expect(delta, `closest ${kind} pair: ${pair[0]} vs ${pair[1]}`).toBeGreaterThanOrEqual(MIN_DELTA_E_CVD); + }); + + // Documented, accepted limitation rather than a silent gap: tritanopia (~0.01% + // prevalence) merges Okabe-Ito's vermillion and reddish purple. Pinned so that any + // future palette change surfaces its tritan behaviour instead of hiding it. + it('has a known tritanopia limitation inherited from Okabe-Ito', () => { + const { delta, pair } = worstPair(MAPPING_PALETTE, 'tritan'); + expect(delta).toBeLessThan(MIN_DELTA_E_CVD); + expect(pair).toEqual(['#D55E00', '#CC79A7']); + }); +}); + +describe('palette assignment', () => { + const pair = (left: string, right: string): AtomMappingPair => ({ + left: { compoundId: left, element: 'O', index: 1 }, + right: { compoundId: right, element: 'O', index: 1 }, + leftAtoms: [{ compoundId: left, element: 'O', index: 1 }], + rightAtoms: [{ compoundId: right, element: 'O', index: 1 }], + hasSymmetryGroup: false, + raw: '', + }); + + it('assigns colours by group order and is stable across runs', () => { + const build = (): readonly string[] => + buildAtomOrbitColorPlan( + Array.from({ length: MAPPING_PALETTE.length + 2 }, (_, i) => + pair(`cpd${String(i + 100).padStart(5, '0')}`, `cpd${String(i + 300).padStart(5, '0')}`)), + [], + ).groups.map((group) => group.color); + + const expected = Array.from( + { length: MAPPING_PALETTE.length + 2 }, + (_, i) => MAPPING_PALETTE[i % MAPPING_PALETTE.length], + ); + expect(build()).toEqual(expected); + expect(build()).toEqual(build()); + }); +}); From b58514db1115112ed0ae61d726b3af336a0e629a Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Wed, 26 Aug 2026 11:15:07 -0500 Subject: [PATCH 33/34] docs(solr): document the full Solr env surface and endpoint switching scenarios Cover legacy, Solr 9, per-corpus temporary, and internal proxy modes. --- .env.example | 2 +- CHANGELOG.md | 3 ++ README.md | 2 +- docs/DEPLOYMENT.md | 87 +++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 00f052b7..d21dfb8a 100644 --- a/.env.example +++ b/.env.example @@ -141,7 +141,7 @@ SOLR_PROXY_UPSTREAM= # The Solr core names for the reactions, compounds, and structures collections. # These must match the names configured in your Solr instance. # -# Override: Required in manual mode, otherwise optional (mode default used) +# Override: reactions/compounds required in manual mode; structures optional (falls back to structures) # Mode default: staging=reactions_staging / compounds_staging / structures_staging # production=reactions / compounds / structures # Fallback: staging: "reactions_staging" / "compounds_staging" / "structures_staging" diff --git a/CHANGELOG.md b/CHANGELOG.md index ac4e3518..adf774b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Solr reaction, compound and structure lookups can now each use their own endpoint and core through separate environment variables, while retaining the shared Solr base when no per-corpus value is set - An optional server-side proxy lets a deployment or local checkout serve Solr from its own origin +### Documentation +- Documented the full Solr environment surface and endpoint switching scenarios for legacy, Solr 9, temporary, and proxied instances + ### Fixed - Structure-core environment overrides now reach browser lookups instead of silently falling back to the shared endpoint diff --git a/README.md b/README.md index 64464864..4d1b1758 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Key configuration constants live in `lib/api/config.ts`: - `MODELSEED_API_URL` – base URL for Poplar (currently `http://poplar.cels.anl.gov:8000` in development). - `USE_MODELSEED_API` – when `true`, user data flows (My Models, My Media, jobs) use `modelseed-api`. - `USE_NEW_PROXY` – when `true`, workspace calls route through the REST proxy at `${MODELSEED_API_URL}/api/workspace`. -- `SOLR_BASE` / `SOLR_REACTIONS_COLLECTION` / `SOLR_COMPOUNDS_COLLECTION` – control Solr endpoint and core selection for biochem pages. +- `NEXT_PUBLIC_SOLR_BASE_URL`, per-corpus `NEXT_PUBLIC_SOLR_*_BASE_URL` overrides, and `NEXT_PUBLIC_SOLR_*_COLLECTION` control Solr endpoints and core selection for biochem pages; see [Solr configuration](docs/DEPLOYMENT.md#solr-configuration). ## Running the App Locally diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index d3e0f74d..2c4252d9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -114,18 +114,42 @@ Status endpoint used by the `/about/version` page for build and service checks. #### `NEXT_PUBLIC_SOLR_BASE_URL` -- Required in manual mode, otherwise optional -Base URL for the Solr search backend. Trailing slash is stripped at runtime. +Shared base URL for the Solr search backend. Trailing slash is normalized at runtime. - **Override:** Required in manual mode, optional otherwise -- **Mode defaults:** `staging=https://staging.modelseed.org/solr/` / `production=https://modelseed.org/solr/` +- **Mode defaults:** `NEXT_PUBLIC_SOLR_BASE_URL_STAGING=https://staging.modelseed.org/solr/` / `NEXT_PUBLIC_SOLR_BASE_URL_PRODUCTION=https://modelseed.org/solr/` - **Fallback:** `{MODELSEED_SITE_BASE_URL}/solr/` +#### `NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL` -- Optional in every mode + +Base URL for only the reactions corpus. When unset, it inherits the shared Solr base URL. + +- **Override:** `NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL` +- **Mode defaults:** `NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_STAGING` / `NEXT_PUBLIC_SOLR_REACTIONS_BASE_URL_PRODUCTION` +- **Fallback:** `NEXT_PUBLIC_SOLR_BASE_URL` resolution; mode-suffixed values are ignored in manual mode + +#### `NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL` -- Optional in every mode + +Base URL for only the compounds corpus. When unset, it inherits the shared Solr base URL. + +- **Override:** `NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL` +- **Mode defaults:** `NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_STAGING` / `NEXT_PUBLIC_SOLR_COMPOUNDS_BASE_URL_PRODUCTION` +- **Fallback:** `NEXT_PUBLIC_SOLR_BASE_URL` resolution; mode-suffixed values are ignored in manual mode + +#### `NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL` -- Optional in every mode + +Base URL for only the structures corpus. When unset, it inherits the shared Solr base URL. + +- **Override:** `NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL` +- **Mode defaults:** `NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_STAGING` / `NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL_PRODUCTION` +- **Fallback:** `NEXT_PUBLIC_SOLR_BASE_URL` resolution; mode-suffixed values are ignored in manual mode + #### `NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION` -- Required in manual mode, otherwise optional Solr core name for the reactions collection. - **Override:** Required in manual mode, optional otherwise -- **Mode defaults:** `staging=reactions_staging` / `production=reactions` +- **Mode defaults:** `NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION_STAGING=reactions_staging` / `NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION_PRODUCTION=reactions` - **Fallback:** `reactions_staging` (staging) / `reactions` (production) #### `NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION` -- Required in manual mode, otherwise optional @@ -133,9 +157,31 @@ Solr core name for the reactions collection. Solr core name for the compounds collection. - **Override:** Required in manual mode, optional otherwise -- **Mode defaults:** `staging=compounds_staging` / `production=compounds` +- **Mode defaults:** `NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_STAGING=compounds_staging` / `NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION_PRODUCTION=compounds` - **Fallback:** `compounds_staging` (staging) / `compounds` (production) +#### `NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION` -- Optional in manual mode + +Solr core name for the structures collection. It remains optional in manual mode to keep pre-existing manual-mode deployments working. + +- **Override:** Optional in every mode +- **Mode defaults:** `NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_STAGING=structures_staging` / `NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION_PRODUCTION=structures` +- **Fallback:** `structures_staging` (staging) / `structures` (production); `structures` in manual mode + +#### `NEXT_PUBLIC_SOLR_NESTED_SCHEMA` -- Optional + +Controls whether reactions and compounds use Solr-9 nested-document queries. + +- **When unset:** Auto-detects by a one-time probe per collection. +- **Values:** `true` / `1` forces Solr-9 nested-document queries (parent documents only); `false` / `0` forces legacy flat behavior. +- **Failure behavior:** A non-OK response or failed probe falls back to legacy flat queries. + +#### `SOLR_PROXY_UPSTREAM` -- Server-only, optional + +Server-only (no `NEXT_PUBLIC_` prefix) and never sent to the browser. When set, `next.config.ts` registers the rewrite `/solr/:path*` to `${SOLR_PROXY_UPSTREAM}/:path*`; when unset or empty, no proxy route exists. This makes a relative base such as `/solr/` work. + +- **Internal hosts:** `http://poplar:8983/solr` is an internal host for development/internal deployments only and must never be used as a public production value. + --- ### Feature Flags (optional) @@ -252,6 +298,39 @@ NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION=reactions NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION=compounds ``` +### Pointing Biochemistry at a Different Solr (legacy, Solr 9, or temporary) + +**Legacy / do nothing.** Leave every Solr override empty. The shared mode default applies, and the nested-schema probe falls back to legacy; this is the zero-configuration state. + +```env +# Leave all NEXT_PUBLIC_SOLR_* and SOLR_PROXY_UPSTREAM values empty +``` + +**New Solr 9, whole site.** Set the shared base (or its active mode variant) and collection names for the new instance. + +```env +NEXT_PUBLIC_SOLR_BASE_URL=https://solr.example.org/solr/ +NEXT_PUBLIC_SOLR_REACTIONS_COLLECTION=reactions +NEXT_PUBLIC_SOLR_COMPOUNDS_COLLECTION=compounds +NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION=structures +``` + +**One corpus only / temporary instance.** Set only the base for the corpus being moved; the other corpora continue using the shared legacy base. + +```env +NEXT_PUBLIC_SOLR_STRUCTURES_BASE_URL=https://solr.example.org/solr/ +NEXT_PUBLIC_SOLR_STRUCTURES_COLLECTION=structures +``` + +**Internal upstream via the built-in proxy.** Set the server-only upstream to an internal URL and use `/solr/` as the shared or per-corpus base. + +```env +SOLR_PROXY_UPSTREAM=http://internal-host:8983/solr +NEXT_PUBLIC_SOLR_BASE_URL=/solr/ +``` + +`NEXT_PUBLIC_*` values are inlined at build time, so restart a dev server and rebuild a deployed application for changes to take effect; `SOLR_PROXY_UPSTREAM` is read when the Next.js configuration loads, so it also requires a restart. + --- ## Runtime Configuration Code From 768b4228e1da65a3e9c334deb580be397a7621c6 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Wed, 26 Aug 2026 15:34:52 -0500 Subject: [PATCH 34/34] test(e2e): cover Solr 9 nested-schema and legacy reaction rendering --- tests/e2e/biochem/solr-schema-compat.spec.ts | 84 ++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/e2e/biochem/solr-schema-compat.spec.ts diff --git a/tests/e2e/biochem/solr-schema-compat.spec.ts b/tests/e2e/biochem/solr-schema-compat.spec.ts new file mode 100644 index 00000000..ba903342 --- /dev/null +++ b/tests/e2e/biochem/solr-schema-compat.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test'; + +const EXPECTED_DELTA_G = '-10.5'; +const EXPECTED_DELTA_G_ERROR = '1.2'; +const EMPTY_SOLR_RESPONSE: { responseHeader: { status: number }; response: { numFound: number; start: number; docs: unknown[] } } = { + responseHeader: { status: 0 }, + response: { numFound: 0, start: 0, docs: [] }, +}; +let interceptedRequests = 0; + +const legacyReaction = { + id: 'rxnCompat001', + name: 'Schema compatibility reaction', + definition: 'cpd00001 => cpd00002', + equation: 'cpd00001 => cpd00002', + deltag: -10.5, + deltagerr: 1.2, + reversibility: '>', + aliases: [], + ec_numbers: [], + pathways: [], + is_obsolete: '0', +}; + +const nestedReaction = { + ...legacyReaction, + thermodynamics: [{ + doc_type: 'thermodynamics', + source_name: 'eQuilibrator', + energy: -10.5, + error: 1.2, + operator: '=', + }], +}; + +test.describe('Biochem reaction Solr schema compatibility', () => { + test.beforeEach(async ({ page }, testInfo) => { + const nested = testInfo.title.includes('Solr 9 nested'); + interceptedRequests = 0; + + await page.route('**/solr/**', async (route) => { + interceptedRequests += 1; + const url = route.request().url(); + let response = EMPTY_SOLR_RESPONSE; + + if (url.includes('rows=0')) { + response = { + ...EMPTY_SOLR_RESPONSE, + response: { ...EMPTY_SOLR_RESPONSE.response, numFound: nested ? 1 : 0 }, + }; + } else if (url.includes('q=id:rxnCompat001')) { + response = { + ...EMPTY_SOLR_RESPONSE, + response: { + ...EMPTY_SOLR_RESPONSE.response, + numFound: 1, + docs: [nested ? nestedReaction : legacyReaction], + }, + }; + } + + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(response) }); + }); + }); + + test.afterEach(async ({}, testInfo) => { + console.log(`${testInfo.title}: intercepted ${interceptedRequests} Solr requests`); + }); + + test('renders reaction thermodynamics from a legacy flat Solr document', async ({ page }) => { + await page.goto('/biochem/reactions/rxnCompat001'); + + await expect(page.getByText('Gibbs free energy change (ΔG)')).toBeVisible(); + await expect(page.getByText(`${EXPECTED_DELTA_G} +/- ${EXPECTED_DELTA_G_ERROR} kcal/mol`)).toBeVisible(); + }); + + test('renders identical thermodynamics from a Solr 9 nested child document', async ({ page }) => { + await page.goto('/biochem/reactions/rxnCompat001'); + + await expect(page.getByText('Thermodynamics')).toBeVisible(); + await expect(page.getByRole('cell', { name: EXPECTED_DELTA_G })).toBeVisible(); + await expect(page.getByRole('cell', { name: EXPECTED_DELTA_G_ERROR })).toBeVisible(); + }); +});