Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.5.1
3.6.0
4 changes: 3 additions & 1 deletion components/ui/MoleculeRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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');
Expand Down
68 changes: 23 additions & 45 deletions lib/utils/atomMappingColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/colorDistance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 45 additions & 0 deletions lib/utils/moleculeHighlights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<rect\b[^>]*>/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(/<svg\b[^>]*>/i, (tag) => (
`${tag}<rect style='opacity:1.0;fill:${color};stroke:none' width='100%' height='100%' x='0' y='0'/>`
));
} 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(/<path\b[^>]*>/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(/^<path\b/i, `<path stroke='${color}' stroke-width='${width}' stroke-linejoin='round' paint-order='stroke'`);
});
} catch {
return svg;
}
}

export function applyAtomLabelColors(svg: string, atomColors: Readonly<Record<number, string>>): string {
if (typeof svg !== 'string' || !atomColors || Object.keys(atomColors).length === 0) return svg;

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/components/MoleculeRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ describe('MoleculeRenderer', () => {
it('colours atom labels without requesting RDKit highlights', async () => {
const { container } = render(<MoleculeRenderer compoundId="cpd00009" smiles="OP" atomColors={{ 0: '#123456' }} />);
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();
Expand Down
69 changes: 54 additions & 15 deletions tests/unit/utils/mappingPaletteSafety.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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'));
Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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']);
});
});

Expand All @@ -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);
}
}
});

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading