diff --git a/CHANGELOG.md b/CHANGELOG.md index a535b1fc..64e9ba23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [3.6.0] - 2026-09-01 + +### Added +- Molecule structure images now render on an off-white `#F5F5F5` canvas with a thin `#333333` outline on atom glyphs, so coloured atom labels stay legible. + +### Fixed +- Atom-mapping colours in reactions with many mapped groups are now clearly distinguishable; the warm/brown band was removed and mapped colours no longer collide with unmapped phosphorus or oxygen atom colours. + +--- + ## [3.5.1] - 2026-08-31 ### Fixed diff --git a/VERSION.md b/VERSION.md index d5c0c991..40c341bd 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -3.5.1 +3.6.0 diff --git a/components/ui/MoleculeRenderer.tsx b/components/ui/MoleculeRenderer.tsx index db6273fa..e7bfe141 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, buildExplicitAtomLabels, buildMoleculeHighlightPlan, elementInventoryFromMolJson, elementSymbolForAtomicNumber } from '@/lib/utils/moleculeHighlights'; +import { applyAtomGlyphOutline, applyAtomLabelColors, applyBondColors, applyMoleculeBackground, buildExplicitAtomLabels, buildMoleculeHighlightPlan, elementInventoryFromMolJson, elementSymbolForAtomicNumber } from '@/lib/utils/moleculeHighlights'; import type { HeavyAtomGraph } from '@/lib/utils/inchiAtomOrder'; /** @@ -164,6 +164,8 @@ export default function MoleculeRenderer({ if (currentBondColors && Object.keys(currentBondColors).length > 0) { svg = applyBondColors(svg, currentBondColors); } + svg = applyMoleculeBackground(svg); + svg = applyAtomGlyphOutline(svg); if (!cancelled) { setSvgString(svg); setState('svg'); diff --git a/lib/utils/atomMappingColors.ts b/lib/utils/atomMappingColors.ts index 3e0deab8..46d759a6 100644 --- a/lib/utils/atomMappingColors.ts +++ b/lib/utils/atomMappingColors.ts @@ -59,55 +59,33 @@ export interface AtomMappingColorPlan { /** * 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. + * Colours are assigned by sorted group order and rendered as RDKit highlight halos, + * atom-label fills, and bond strokes on the white molecule canvas. The set uses six + * hue families; repeated green and blue families are split by about 31 L* so they + * read as dark versus bright green and deep versus sky blue rather than near-twins. + * The warm band is deliberately excluded: on white, the 3:1 contrast requirement + * makes warm colours dark, which reads as brown; it also avoids collisions with + * RDKit's unmapped phosphorus and oxygen colours. * - * Design constraints, all asserted by `tests/unit/utils/mappingPaletteSafety.test.ts`: + * Measured for this eight-colour set: minimum normal-vision dE76 is 48.6; minimum + * protan/deutan dE76 is 23.4; minimum contrast is 3.01:1 against white and 2.22:1 + * against black; minimum dE76 from black is 50; and minimum dE76 from any RDKit + * default (#FF0000, #FF7F00, #0000FF, #CCCC00, #00FF00) is 29.4. * - * 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. - * 6. Each reaction selects the palette subset that maximises its minimum normal, - * protan, and deutan separation before assigning sorted group order; for four - * groups this improves the minimum dE76 from 19.00 to 36.14. - * - * 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. + * `selectMappingColors` chooses a maximum-minimum-separation subset when the group + * count fits the palette. When it exceeds the palette length, it returns this full + * palette unchanged; `buildAtomMappingColorPlan` then cycles it with + * `index % selection.length`, so distinct groups can share a colour. */ export const MAPPING_PALETTE: readonly string[] = [ - '#0072B2', // blue - '#D55E00', // vermillion - '#009E73', // bluish green - '#CC79A7', // reddish purple - '#FA3C5A', // rose red - '#0A5A14', // deep green - '#960A82', // magenta - '#0A5AE6', // indigo blue + '#355214', // dark green + '#3FAA18', // bright green + '#00A398', // teal + '#2994FF', // sky blue + '#0B26D5', // deep blue + '#6F2183', // violet + '#FF14EF', // magenta + '#E00069', // crimson ]; const paletteDistances: readonly (readonly number[])[] = MAPPING_PALETTE.map((color) => diff --git a/lib/utils/colorDistance.ts b/lib/utils/colorDistance.ts index e14d36f7..9f309d5e 100644 --- a/lib/utils/colorDistance.ts +++ b/lib/utils/colorDistance.ts @@ -58,7 +58,7 @@ export function deltaE76(a: string, b: string): number { /** * Minimum normal/protan/deutan distance. Tritanopia is excluded because accepted - * Okabe-Ito #D55E00/#CC79A7 tritan dE76 0.96 would flatten every subset ranking. + * #00A398/#2994FF tritan dE76 4.95 would flatten every subset ranking. */ export function perceptualDistance(a: string, b: string): number { return Math.min( diff --git a/lib/utils/moleculeHighlights.ts b/lib/utils/moleculeHighlights.ts index a2690696..47b7a28e 100644 --- a/lib/utils/moleculeHighlights.ts +++ b/lib/utils/moleculeHighlights.ts @@ -109,6 +109,51 @@ export function buildMoleculeHighlightPlan( return { atomColors, bondColors }; } +export const DEFAULT_MOLECULE_BACKGROUND_COLOR = '#F5F5F5'; +export const DEFAULT_ATOM_GLYPH_OUTLINE_COLOR = '#333333'; +export const DEFAULT_ATOM_GLYPH_OUTLINE_WIDTH = 1.0; + +export function applyMoleculeBackground(svg: string, color = DEFAULT_MOLECULE_BACKGROUND_COLOR): string { + if (typeof svg !== 'string' || !svg) return svg; + + try { + let backgroundFound = false; + const withBackground = svg.replace(/]*>/gi, (tag) => { + if (backgroundFound || /\bclass=(['"])(.*?)\1/.test(tag)) return tag; + const styleMatch = /\bstyle=(['"])(.*?)\1/.exec(tag); + if (!styleMatch || !/stroke:\s*none\b/i.test(styleMatch[2])) return tag; + backgroundFound = true; + return tag.replace(/fill:\s*#[0-9a-f]{6}/i, `fill:${color}`); + }); + if (backgroundFound) return withBackground; + return svg.replace(/]*>/i, (tag) => ( + `${tag}` + )); + } catch { + return svg; + } +} + +export function applyAtomGlyphOutline( + svg: string, + options?: { color?: string; width?: number }, +): string { + if (typeof svg !== 'string' || !svg) return svg; + + const color = options?.color ?? DEFAULT_ATOM_GLYPH_OUTLINE_COLOR; + const width = options?.width ?? DEFAULT_ATOM_GLYPH_OUTLINE_WIDTH; + + try { + return svg.replace(/]*>/gi, (tag) => { + const classMatch = /\bclass=(['"])(.*?)\1/.exec(tag); + if (!classMatch || !/^atom-(\d+)(?:\s|$)/.test(classMatch[2]) || /\bstroke\s*=/.test(tag)) return tag; + return tag.replace(/^>): string { if (typeof svg !== 'string' || !atomColors || Object.keys(atomColors).length === 0) return svg; diff --git a/package-lock.json b/package-lock.json index 26f600e5..e8142d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelseed-ui", - "version": "3.5.1", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelseed-ui", - "version": "3.5.1", + "version": "3.6.0", "dependencies": { "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index c451af04..0c89881e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modelseed-ui", - "version": "3.5.1", + "version": "3.6.0", "private": true, "scripts": { "predev": "node scripts/sync-version-from-env.mjs", diff --git a/tests/unit/components/MoleculeRenderer.test.tsx b/tests/unit/components/MoleculeRenderer.test.tsx index 6c427e4b..7fb30587 100644 --- a/tests/unit/components/MoleculeRenderer.test.tsx +++ b/tests/unit/components/MoleculeRenderer.test.tsx @@ -20,6 +20,8 @@ describe('MoleculeRenderer', () => { it('colours atom labels without requesting RDKit highlights', async () => { const { container } = render(); await waitFor(() => expect(container.querySelector('[class="atom-0"]')?.getAttribute('fill')).toBe('#123456')); + expect(container.querySelector('[class="atom-0"]')?.getAttribute('stroke')).toBe('#333333'); + expect(container.querySelector('[class="atom-0"]')?.getAttribute('stroke-width')).toBe('1'); const mol = getMol.mock.results.at(-1)?.value; expect(mol.get_svg).toHaveBeenCalled(); expect(mol.get_svg_with_highlights).not.toHaveBeenCalled(); diff --git a/tests/unit/utils/mappingPaletteSafety.test.ts b/tests/unit/utils/mappingPaletteSafety.test.ts index 0904b0d3..e8c69335 100644 --- a/tests/unit/utils/mappingPaletteSafety.test.ts +++ b/tests/unit/utils/mappingPaletteSafety.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MAPPING_PALETTE, selectMappingColors } from '@/lib/utils/atomMappingColors'; +import { buildAtomMappingColorPlan, MAPPING_PALETTE, selectMappingColors } from '@/lib/utils/atomMappingColors'; import { deltaE76, perceptualDistance } from '@/lib/utils/colorDistance'; import { buildAtomOrbitColorPlan } from '@/lib/utils/atomOrbitColors'; import type { AtomMappingPair } from '@/lib/utils/atomMapping'; @@ -27,6 +27,9 @@ const MIN_DELTA_E_NORMAL = 25; /** ...and under the common red-green deficiencies. */ const MIN_DELTA_E_CVD = 15; +// At these sizes the maximum-minimum subset beats the palette-order prefix. +const COUNTS_WHERE_SELECTION_BEATS_PREFIX = new Set([2, 3, 4, 5, 7]); + type Rgb = readonly [number, number, number]; const hexToRgb = (hex: string): Rgb => { @@ -121,7 +124,7 @@ describe('colour maths used by these checks', () => { 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); + expect(deltaE('#355214', '#355214')).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')); @@ -130,11 +133,11 @@ describe('colour maths used by these checks', () => { }); 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. + // The retired 12-colour set: brown #8C564B and green #20854E were + // indistinguishable under deuteranopia. Proves these thresholds can fail. const legacy = [ - '#0072B2', '#D55E00', '#009E73', '#CC79A7', '#E69F00', '#56B4E9', - '#8C564B', '#7F3FBF', '#BC3C29', '#20854E', '#6F99AD', '#EE4C97', + '#E69F00', '#56B4E9', '#8C564B', '#7F3FBF', '#BC3C29', '#20854E', + '#6F99AD', '#EE4C97', '#0072B2', '#D55E00', '#009E73', '#CC79A7', ]; expect(worstPair(legacy, 'deutan').delta).toBeLessThan(MIN_DELTA_E_CVD); expect(Math.min(...legacy.map((c) => contrastRatio(c, CANVAS)))).toBeLessThan(MIN_CONTRAST_ON_CANVAS); @@ -148,8 +151,8 @@ describe('MAPPING_PALETTE', () => { 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('uses the high-salience six-family palette in its documented order', () => { + expect(MAPPING_PALETTE).toEqual(['#355214', '#3FAA18', '#00A398', '#2994FF', '#0B26D5', '#6F2183', '#FF14EF', '#E00069']); }); it('drops the low-contrast and CVD-confusable entries of the previous palette', () => { @@ -181,13 +184,12 @@ describe('MAPPING_PALETTE', () => { 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', () => { + // Tritanopia is excluded from subset ranking; pin its actual worst pair so a + // future palette change makes that trade-off visible. + it('has the documented tritanopia limitation', () => { const { delta, pair } = worstPair(MAPPING_PALETTE, 'tritan'); - expect(delta).toBeLessThan(MIN_DELTA_E_CVD); - expect(pair).toEqual(['#D55E00', '#CC79A7']); + expect(delta).toBeCloseTo(4.95, 1); + expect(pair).toEqual(['#00A398', '#2994FF']); }); }); @@ -198,7 +200,13 @@ describe('selectMappingColors', () => { const sequential = worstPerceptualPair(MAPPING_PALETTE.slice(0, count)); expect(selected).toBeGreaterThanOrEqual(sequential); expect(selected).toBeGreaterThanOrEqual(MIN_DELTA_E_CVD); - if (count < MAPPING_PALETTE.length) expect(selected).toBeGreaterThan(sequential); + // At 6 and 8, the leading palette run is already an optimal subset, so the + // maximiser agrees with the prefix; this tie is never a regression. + if (COUNTS_WHERE_SELECTION_BEATS_PREFIX.has(count)) { + expect(selected).toBeGreaterThan(sequential); + } else { + expect(selected).toBeCloseTo(sequential, 9); + } } }); @@ -232,6 +240,37 @@ describe('selectMappingColors', () => { ); } }); + + it('addresses the rxn00018 high-salience mapping colour report', () => { + expect(selectMappingColors(8)).toEqual([ + '#355214', '#3FAA18', '#00A398', '#2994FF', + '#0B26D5', '#6F2183', '#FF14EF', '#E00069', + ]); + expect(selectMappingColors(4)).toEqual(['#3FAA18', '#00A398', '#2994FF', '#0B26D5']); + for (const [left, right] of pairs(MAPPING_PALETTE)) { + expect(deltaE76(left, right), `${left} vs ${right}`).toBeGreaterThanOrEqual(45); + } + for (const color of MAPPING_PALETTE) { + for (const rdkitDefault of ['#FF0000', '#FF7F00', '#0000FF', '#CCCC00', '#00FF00']) { + expect(deltaE76(color, rdkitDefault), `${color} vs ${rdkitDefault}`).toBeGreaterThanOrEqual(25); + } + } + expect(selectMappingColors(MAPPING_PALETTE.length + 1)).toEqual(MAPPING_PALETTE); + const mappingPairs: AtomMappingPair[] = Array.from({ length: MAPPING_PALETTE.length + 1 }, (_, index) => ({ + left: { compoundId: `cpd${String(index).padStart(5, '0')}`, element: 'O', index: 1 }, + right: { compoundId: `cpd${String(index + 100).padStart(5, '0')}`, element: 'O', index: 1 }, + leftAtoms: [{ compoundId: `cpd${String(index).padStart(5, '0')}`, element: 'O', index: 1 }], + rightAtoms: [{ compoundId: `cpd${String(index + 100).padStart(5, '0')}`, element: 'O', index: 1 }], + hasSymmetryGroup: false, + raw: '', + })); + const inventories = Object.fromEntries(mappingPairs.flatMap(({ left, right }) => [ + [left.compoundId, { O: 1 }], [right.compoundId, { O: 1 }], + ])); + const plan = buildAtomMappingColorPlan(mappingPairs, inventories); + expect(plan.legend).toHaveLength(MAPPING_PALETTE.length + 1); + expect(plan.legend[0].color).toBe(plan.legend[MAPPING_PALETTE.length].color); + }); }); describe('palette assignment', () => { diff --git a/tests/unit/utils/moleculeHighlights.test.ts b/tests/unit/utils/moleculeHighlights.test.ts index 56091fa5..2ce0e711 100644 --- a/tests/unit/utils/moleculeHighlights.test.ts +++ b/tests/unit/utils/moleculeHighlights.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + applyAtomGlyphOutline, applyAtomLabelColors, applyBondColors, + applyMoleculeBackground, buildExplicitAtomLabels, buildMoleculeHighlightPlan, elementInventoryFromMolJson, @@ -71,6 +73,37 @@ describe('moleculeHighlights', () => { expect(plan.bondColors).toEqual({ 0: '#ff0000' }); }); + it('replaces the RDKit canvas background and inserts one when absent', () => { + const rdkitSvg = ""; + expect(applyMoleculeBackground(rdkitSvg)).toContain("fill:#F5F5F5;stroke:none"); + expect(applyMoleculeBackground('')).toContain(""); + }); + + it('leaves classified rects untouched', () => { + const rect = ""; + expect(applyMoleculeBackground(`${rect}`)).toContain(rect); + }); + + it('outlines only atom glyph paths while preserving fill', () => { + const glyph = ""; + const result = applyAtomGlyphOutline(glyph); + expect(result).toContain("stroke='#333333'"); + expect(result).toContain("stroke-width='1'"); + expect(result).toContain("paint-order='stroke'"); + expect(result).toContain("fill='#FF0000'"); + expect(applyAtomGlyphOutline(BOND_PATH)).toBe(BOND_PATH); + const ellipse = ""; + expect(applyAtomGlyphOutline(ellipse)).toBe(ellipse); + expect(applyAtomGlyphOutline(result)).toBe(result); + }); + + it('returns non-string and empty SVG inputs unchanged', () => { + expect(applyMoleculeBackground('')).toBe(''); + expect(applyAtomGlyphOutline('')).toBe(''); + expect(applyMoleculeBackground(null as unknown as string)).toBeNull(); + expect(applyAtomGlyphOutline(null as unknown as string)).toBeNull(); + }); + it('recolours atom label fill attributes and styles', () => { const fillAttribute = ""; const fillStyle = ""; @@ -114,44 +147,44 @@ describe('moleculeHighlights', () => { it('recolours a heteroatom half-bond', () => { const svg = ""; - const result = applyBondColors(svg, { 0: '#0072B2' }); - expect(result).toContain('stroke:#0072B2'); + const result = applyBondColors(svg, { 0: '#355214' }); + expect(result).toContain('stroke:#355214'); 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); + const result = applyBondColors(svg, { 0: '#355214' }); + expect(result.match(/stroke:#355214/g)).toHaveLength(2); }); it('recolours every hex stroke declaration in a bond style', () => { const svg = ""; - expect(applyBondColors(svg, { 0: '#0072B2' })).toBe( - "", + expect(applyBondColors(svg, { 0: '#355214' })).toBe( + "", ); }); it('recolours standalone stroke attributes', () => { const svg = ''; - expect(applyBondColors(svg, { 1: '#009E73' })).toContain('stroke="#009E73"'); + expect(applyBondColors(svg, { 1: '#3FAA18' })).toContain('stroke="#3FAA18"'); }); it('leaves fills and atom label glyphs untouched', () => { const atomLabel = ""; const bond = ""; - const result = applyBondColors(`${atomLabel}${bond}`, { 0: '#0072B2' }); + const result = applyBondColors(`${atomLabel}${bond}`, { 0: '#355214' }); expect(result).toContain(atomLabel); - expect(result).toContain('fill:none;stroke:#0072B2'); + expect(result).toContain('fill:none;stroke:#355214'); }); it('leaves non-hex bond strokes unchanged', () => { const svg = ""; - expect(applyBondColors(svg, { 0: '#0072B2' })).toBe(svg); + expect(applyBondColors(svg, { 0: '#355214' })).toBe(svg); }); it('recolours lowercase hex bond strokes', () => { const svg = ""; - expect(applyBondColors(svg, { 0: '#0072B2' })).toContain('stroke:#0072B2'); + expect(applyBondColors(svg, { 0: '#355214' })).toContain('stroke:#355214'); }); });