react-cheminfo 0.74.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ecosystem/core/sites.d.ts.map +1 -1
- package/lib/ecosystem/core/sites.js +26 -0
- package/lib/ecosystem/core/sites.js.map +1 -1
- package/lib/ecosystem/core/types.d.ts +1 -1
- package/lib/ecosystem/core/types.d.ts.map +1 -1
- package/lib/ecosystem/ui/glyphs.d.ts.map +1 -1
- package/lib/ecosystem/ui/glyphs.js +7 -0
- package/lib/ecosystem/ui/glyphs.js.map +1 -1
- package/lib/formula/core/elementRanges.d.ts +25 -0
- package/lib/formula/core/elementRanges.d.ts.map +1 -0
- package/lib/formula/core/elementRanges.js +35 -0
- package/lib/formula/core/elementRanges.js.map +1 -0
- package/lib/formula/core/formulaCandidates.d.ts +59 -0
- package/lib/formula/core/formulaCandidates.d.ts.map +1 -0
- package/lib/formula/core/formulaCandidates.js +50 -0
- package/lib/formula/core/formulaCandidates.js.map +1 -0
- package/lib/formula/core/index.d.ts +4 -0
- package/lib/formula/core/index.d.ts.map +1 -1
- package/lib/formula/core/index.js +2 -0
- package/lib/formula/core/index.js.map +1 -1
- package/lib/formula/ui/ElementRangeFields.d.ts +19 -0
- package/lib/formula/ui/ElementRangeFields.d.ts.map +1 -0
- package/lib/formula/ui/ElementRangeFields.js +23 -0
- package/lib/formula/ui/ElementRangeFields.js.map +1 -0
- package/lib/formula/ui/FormulaLookup.d.ts +47 -0
- package/lib/formula/ui/FormulaLookup.d.ts.map +1 -0
- package/lib/formula/ui/FormulaLookup.js +74 -0
- package/lib/formula/ui/FormulaLookup.js.map +1 -0
- package/lib/formula/ui/FormulaLookupResults.d.ts +20 -0
- package/lib/formula/ui/FormulaLookupResults.d.ts.map +1 -0
- package/lib/formula/ui/FormulaLookupResults.js +45 -0
- package/lib/formula/ui/FormulaLookupResults.js.map +1 -0
- package/lib/formula/ui/index.d.ts +2 -0
- package/lib/formula/ui/index.d.ts.map +1 -1
- package/lib/formula/ui/index.js +1 -0
- package/lib/formula/ui/index.js.map +1 -1
- package/lib/i18n/core/chromeCatalog.d.ts +1 -1
- package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
- package/lib/locales/de.json +16 -0
- package/lib/locales/en.json +16 -0
- package/lib/locales/es.json +16 -0
- package/lib/locales/fr.json +16 -0
- package/lib/locales/it.json +16 -0
- package/lib/structure/ui/useToolbarFloor.d.ts.map +1 -1
- package/lib/structure/ui/useToolbarFloor.js +20 -10
- package/lib/structure/ui/useToolbarFloor.js.map +1 -1
- package/package.json +4 -3
- package/src/ecosystem/core/sites.ts +26 -0
- package/src/ecosystem/core/types.ts +3 -1
- package/src/ecosystem/ui/glyphs.tsx +46 -0
- package/src/formula/core/elementRanges.ts +44 -0
- package/src/formula/core/formulaCandidates.ts +109 -0
- package/src/formula/core/index.ts +15 -0
- package/src/formula/ui/ElementRangeFields.tsx +65 -0
- package/src/formula/ui/FormulaLookup.tsx +184 -0
- package/src/formula/ui/FormulaLookupResults.tsx +89 -0
- package/src/formula/ui/index.ts +2 -0
- package/src/locales/de.json +16 -0
- package/src/locales/en.json +16 -0
- package/src/locales/es.json +16 -0
- package/src/locales/fr.json +16 -0
- package/src/locales/it.json +16 -0
- package/src/structure/ui/useToolbarFloor.ts +19 -9
- package/styles/chrome.css +133 -0
|
@@ -308,6 +308,28 @@ export const GLYPHS: Record<SiteId, (alt: string) => ReactNode> = {
|
|
|
308
308
|
<circle cx="19" cy="9" r="3" fill={alt} />
|
|
309
309
|
</>
|
|
310
310
|
),
|
|
311
|
+
// Three peaks on a baseline and the ring they are read back to: the spectra
|
|
312
|
+
// are what a student is given, the structure is the answer, so the ring
|
|
313
|
+
// carries the answering colour.
|
|
314
|
+
spectroscopy: (alt) => (
|
|
315
|
+
<>
|
|
316
|
+
<path
|
|
317
|
+
d="M4 25h7l1.5-8 1.5 8h3l1.5-5 1.5 5h2l1.75-14 1.75 14H28"
|
|
318
|
+
fill="none"
|
|
319
|
+
stroke="#ffffff"
|
|
320
|
+
strokeWidth="2"
|
|
321
|
+
strokeLinecap="round"
|
|
322
|
+
strokeLinejoin="round"
|
|
323
|
+
/>
|
|
324
|
+
<path
|
|
325
|
+
d="M10 4 14.76 6.75V12.25L10 15 5.24 12.25V6.75Z"
|
|
326
|
+
fill="none"
|
|
327
|
+
stroke={alt}
|
|
328
|
+
strokeWidth="2.2"
|
|
329
|
+
strokeLinejoin="round"
|
|
330
|
+
/>
|
|
331
|
+
</>
|
|
332
|
+
),
|
|
311
333
|
// The site's own favicon: the double harpoon, one direction per colour.
|
|
312
334
|
equilibrium: (alt) => (
|
|
313
335
|
<g
|
|
@@ -556,4 +578,28 @@ export const GLYPHS: Record<SiteId, (alt: string) => ReactNode> = {
|
|
|
556
578
|
<rect x="6" y="24" width="14" height="4" rx="2" fill={alt} />
|
|
557
579
|
</>
|
|
558
580
|
),
|
|
581
|
+
// Two atoms on a bond, caught mid-stretch, over the baseline of a spectrum.
|
|
582
|
+
// The lighter atom carries the answering colour.
|
|
583
|
+
vibrations: (alt) => (
|
|
584
|
+
<>
|
|
585
|
+
<line
|
|
586
|
+
x1="10"
|
|
587
|
+
y1="11"
|
|
588
|
+
x2="23"
|
|
589
|
+
y2="11"
|
|
590
|
+
stroke="#ffffff"
|
|
591
|
+
strokeWidth="2.5"
|
|
592
|
+
/>
|
|
593
|
+
<circle cx="10" cy="11" r="5" fill="#ffffff" />
|
|
594
|
+
<circle cx="23" cy="11" r="3.5" fill={alt} />
|
|
595
|
+
<path
|
|
596
|
+
d="M4 26h4.5l2-5 2 5h4l2-8 2 8H28"
|
|
597
|
+
fill="none"
|
|
598
|
+
stroke="#ffffff"
|
|
599
|
+
strokeWidth="2"
|
|
600
|
+
strokeLinecap="round"
|
|
601
|
+
strokeLinejoin="round"
|
|
602
|
+
/>
|
|
603
|
+
</>
|
|
604
|
+
),
|
|
559
605
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** One element a formula search may use, and how many of its atoms. */
|
|
2
|
+
export interface ElementRange {
|
|
3
|
+
/** The element symbol: `C`, `Cl`. */
|
|
4
|
+
readonly element: string;
|
|
5
|
+
/** Fewest atoms of it a formula carries. */
|
|
6
|
+
readonly min: number;
|
|
7
|
+
/** Most atoms of it a formula carries; `0` leaves the element out. */
|
|
8
|
+
readonly max: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The elements a formula lookup offers, with the range it opens on. Carbon and
|
|
13
|
+
* hydrogen are wide because every organic molecule holds both, and the
|
|
14
|
+
* heteroatoms open wide enough that a molecule carrying four of one of them is
|
|
15
|
+
* listed without the reader having to suspect it first.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_ELEMENT_RANGES: readonly ElementRange[] = [
|
|
18
|
+
{ element: 'C', min: 1, max: 30 },
|
|
19
|
+
{ element: 'H', min: 1, max: 30 },
|
|
20
|
+
{ element: 'O', min: 0, max: 6 },
|
|
21
|
+
{ element: 'N', min: 0, max: 4 },
|
|
22
|
+
{ element: 'S', min: 0, max: 4 },
|
|
23
|
+
{ element: 'F', min: 0, max: 4 },
|
|
24
|
+
{ element: 'Cl', min: 0, max: 4 },
|
|
25
|
+
{ element: 'Br', min: 0, max: 4 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The ranges as `mf-finder` reads them: `C1-30 H1-30 O0-6`. An element whose
|
|
30
|
+
* upper bound is zero is left out rather than written `F0-0`, and a bound below
|
|
31
|
+
* zero or past the other one is brought back inside it.
|
|
32
|
+
* @param ranges - The element ranges.
|
|
33
|
+
* @returns The range string, empty when no element is left.
|
|
34
|
+
*/
|
|
35
|
+
export function formatElementRanges(ranges: readonly ElementRange[]): string {
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
for (const range of ranges) {
|
|
38
|
+
const max = Math.max(0, Math.trunc(range.max));
|
|
39
|
+
if (max === 0) continue;
|
|
40
|
+
const min = Math.min(Math.max(0, Math.trunc(range.min)), max);
|
|
41
|
+
parts.push(`${range.element}${min}-${max}`);
|
|
42
|
+
}
|
|
43
|
+
return parts.join(' ');
|
|
44
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { MFResult } from 'mf-finder';
|
|
2
|
+
|
|
3
|
+
import type { ElementRange } from './elementRanges.ts';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_ELEMENT_RANGES,
|
|
6
|
+
formatElementRanges,
|
|
7
|
+
} from './elementRanges.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The window a formula lookup enumerates, in ppm. It is wide on purpose: the
|
|
11
|
+
* accuracy a spectrum was measured at is read off the ppm of each formula, so
|
|
12
|
+
* the window only has to hold the closest ones as a list rather than act as a
|
|
13
|
+
* filter.
|
|
14
|
+
*/
|
|
15
|
+
export const FORMULA_LOOKUP_PRECISION = 500;
|
|
16
|
+
|
|
17
|
+
/** How many formulas a lookup lists, closest first. */
|
|
18
|
+
export const FORMULA_LOOKUP_LIMIT = 100;
|
|
19
|
+
|
|
20
|
+
/** A molecular formula whose monoisotopic mass is near the one searched. */
|
|
21
|
+
export interface FormulaCandidate {
|
|
22
|
+
/** The formula of the neutral molecule: `C10H14O`. */
|
|
23
|
+
mf: string;
|
|
24
|
+
/** Its monoisotopic mass, in Da. */
|
|
25
|
+
em: number;
|
|
26
|
+
/** Its unsaturation: rings plus double bonds. */
|
|
27
|
+
unsaturation: number;
|
|
28
|
+
/** How far the searched mass is from it, in ppm; positive when heavier. */
|
|
29
|
+
ppm: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The formulas a search kept, and how many fit. */
|
|
33
|
+
export interface FormulaCandidates {
|
|
34
|
+
/** The closest formulas, closest first. */
|
|
35
|
+
candidates: FormulaCandidate[];
|
|
36
|
+
/** How many formulas fit the window, the ones left out included. */
|
|
37
|
+
total: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What {@link findFormulaCandidates} searches. */
|
|
41
|
+
export interface FormulaCandidateOptions {
|
|
42
|
+
/**
|
|
43
|
+
* The elements, and how many atoms of each a formula may carry.
|
|
44
|
+
* @default DEFAULT_ELEMENT_RANGES
|
|
45
|
+
*/
|
|
46
|
+
ranges?: readonly ElementRange[];
|
|
47
|
+
/**
|
|
48
|
+
* The window around the mass, in ppm.
|
|
49
|
+
* @default FORMULA_LOOKUP_PRECISION
|
|
50
|
+
*/
|
|
51
|
+
precision?: number;
|
|
52
|
+
/**
|
|
53
|
+
* How many formulas are kept, closest first.
|
|
54
|
+
* @default FORMULA_LOOKUP_LIMIT
|
|
55
|
+
*/
|
|
56
|
+
limit?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The neutral molecular formulas whose monoisotopic mass lies within the
|
|
61
|
+
* window of a mass, closest first. Only formulas with a whole, non-negative
|
|
62
|
+
* unsaturation are kept: a neutral, even-electron molecule has no other.
|
|
63
|
+
*
|
|
64
|
+
* `mf-finder` is loaded on the first search, so a page that only draws
|
|
65
|
+
* formulas never downloads it.
|
|
66
|
+
* @param mass - The monoisotopic mass of the neutral molecule, in Da.
|
|
67
|
+
* @param options - See {@link FormulaCandidateOptions}.
|
|
68
|
+
* @returns The closest formulas, and how many fit the window.
|
|
69
|
+
*/
|
|
70
|
+
export async function findFormulaCandidates(
|
|
71
|
+
mass: number,
|
|
72
|
+
options: FormulaCandidateOptions = {},
|
|
73
|
+
): Promise<FormulaCandidates> {
|
|
74
|
+
const {
|
|
75
|
+
ranges = DEFAULT_ELEMENT_RANGES,
|
|
76
|
+
precision = FORMULA_LOOKUP_PRECISION,
|
|
77
|
+
limit = FORMULA_LOOKUP_LIMIT,
|
|
78
|
+
} = options;
|
|
79
|
+
const range = formatElementRanges(ranges);
|
|
80
|
+
const kept = Math.floor(limit);
|
|
81
|
+
if (!Number.isFinite(mass) || mass <= 0 || range === '' || !(kept > 0)) {
|
|
82
|
+
return { candidates: [], total: 0 };
|
|
83
|
+
}
|
|
84
|
+
const { findMFs } = await import('mf-finder');
|
|
85
|
+
// Declared as an array of results, `findMFs` resolves to `{ mfs, info }`.
|
|
86
|
+
const found = (await findMFs(mass, {
|
|
87
|
+
ranges: range,
|
|
88
|
+
precision,
|
|
89
|
+
limit: kept,
|
|
90
|
+
// Without it, a formula with no charge is never compared to the mass.
|
|
91
|
+
allowNeutral: true,
|
|
92
|
+
filter: { unsaturation: { min: 0, onlyInteger: true } },
|
|
93
|
+
})) as unknown as FinderAnswer;
|
|
94
|
+
const candidates: FormulaCandidate[] = [];
|
|
95
|
+
for (const result of found.mfs) {
|
|
96
|
+
candidates.push({
|
|
97
|
+
mf: result.mf,
|
|
98
|
+
em: result.em,
|
|
99
|
+
unsaturation: result.unsaturation,
|
|
100
|
+
ppm: result.ms.ppm,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { candidates, total: found.info.numberResults };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface FinderAnswer {
|
|
107
|
+
mfs: MFResult[];
|
|
108
|
+
info: { numberResults: number };
|
|
109
|
+
}
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
export type { ElementRange } from './elementRanges.ts';
|
|
2
|
+
export {
|
|
3
|
+
DEFAULT_ELEMENT_RANGES,
|
|
4
|
+
formatElementRanges,
|
|
5
|
+
} from './elementRanges.ts';
|
|
6
|
+
export type {
|
|
7
|
+
FormulaCandidate,
|
|
8
|
+
FormulaCandidateOptions,
|
|
9
|
+
FormulaCandidates,
|
|
10
|
+
} from './formulaCandidates.ts';
|
|
11
|
+
export {
|
|
12
|
+
FORMULA_LOOKUP_LIMIT,
|
|
13
|
+
FORMULA_LOOKUP_PRECISION,
|
|
14
|
+
findFormulaCandidates,
|
|
15
|
+
} from './formulaCandidates.ts';
|
|
1
16
|
export type { CanonicalFormulaOptions } from './formulaText.ts';
|
|
2
17
|
export {
|
|
3
18
|
atomCounts,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useChromeT } from '../../i18n/ui/useT.ts';
|
|
4
|
+
import { NumberInput } from '../../number/ui/NumberInput.tsx';
|
|
5
|
+
import type { ElementRange } from '../core/elementRanges.ts';
|
|
6
|
+
|
|
7
|
+
/** What {@link ElementRangeFields} edits. */
|
|
8
|
+
export interface ElementRangeFieldsProps {
|
|
9
|
+
/** The elements, and their bounds as they stand. */
|
|
10
|
+
ranges: readonly ElementRange[];
|
|
11
|
+
/** Called with the element, the bound edited and its new value. */
|
|
12
|
+
onChange: (element: string, bound: 'min' | 'max', value: number) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The bounds of each element, written as the range they are read as:
|
|
17
|
+
* `C 1 – 30`. The dash says which box is which, so the grid needs no heading
|
|
18
|
+
* row and fits two elements to a line; each box carries its own accessible
|
|
19
|
+
* name instead.
|
|
20
|
+
* @param props - See {@link ElementRangeFieldsProps}.
|
|
21
|
+
* @returns The grid of ranges.
|
|
22
|
+
*/
|
|
23
|
+
export function ElementRangeFields(
|
|
24
|
+
props: ElementRangeFieldsProps,
|
|
25
|
+
): ReactElement {
|
|
26
|
+
const { ranges, onChange } = props;
|
|
27
|
+
const t = useChromeT();
|
|
28
|
+
return (
|
|
29
|
+
<div className="formula-lookup__ranges">
|
|
30
|
+
{ranges.map((range) => (
|
|
31
|
+
<div key={range.element} className="formula-lookup__range">
|
|
32
|
+
<span className="formula-lookup__element">{range.element}</span>
|
|
33
|
+
<NumberInput
|
|
34
|
+
size="small"
|
|
35
|
+
buttons={false}
|
|
36
|
+
fill
|
|
37
|
+
integer
|
|
38
|
+
min={0}
|
|
39
|
+
max={range.max}
|
|
40
|
+
value={range.min}
|
|
41
|
+
ariaLabel={t('formula.lookup.fewest', {
|
|
42
|
+
element: range.element,
|
|
43
|
+
})}
|
|
44
|
+
onChange={(value) => {
|
|
45
|
+
onChange(range.element, 'min', value);
|
|
46
|
+
}}
|
|
47
|
+
/>
|
|
48
|
+
<span className="formula-lookup__dash">–</span>
|
|
49
|
+
<NumberInput
|
|
50
|
+
size="small"
|
|
51
|
+
buttons={false}
|
|
52
|
+
fill
|
|
53
|
+
integer
|
|
54
|
+
min={range.min}
|
|
55
|
+
value={range.max}
|
|
56
|
+
ariaLabel={t('formula.lookup.most', { element: range.element })}
|
|
57
|
+
onChange={(value) => {
|
|
58
|
+
onChange(range.element, 'max', value);
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Button } from '@blueprintjs/core';
|
|
2
|
+
import type { ReactElement } from 'react';
|
|
3
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useAsync } from '../../hooks/ui/useAsync.ts';
|
|
6
|
+
import { useDebouncedValue } from '../../hooks/ui/useDebouncedValue.ts';
|
|
7
|
+
import { useChromeT } from '../../i18n/ui/useT.ts';
|
|
8
|
+
import { NumberInput } from '../../number/ui/NumberInput.tsx';
|
|
9
|
+
import { joinClassNames } from '../../shared/ui/joinClassNames.ts';
|
|
10
|
+
import type { ElementRange } from '../core/elementRanges.ts';
|
|
11
|
+
import { DEFAULT_ELEMENT_RANGES } from '../core/elementRanges.ts';
|
|
12
|
+
import type { FormulaCandidates } from '../core/formulaCandidates.ts';
|
|
13
|
+
import {
|
|
14
|
+
FORMULA_LOOKUP_LIMIT,
|
|
15
|
+
findFormulaCandidates,
|
|
16
|
+
} from '../core/formulaCandidates.ts';
|
|
17
|
+
|
|
18
|
+
import { ElementRangeFields } from './ElementRangeFields.tsx';
|
|
19
|
+
import { FormulaLookupResults } from './FormulaLookupResults.tsx';
|
|
20
|
+
|
|
21
|
+
/** What {@link FormulaLookup} opens on, and who owns the mass. */
|
|
22
|
+
export interface FormulaLookupProps {
|
|
23
|
+
/**
|
|
24
|
+
* The elements offered and the range each opens on, read when the panel
|
|
25
|
+
* mounts and again when it is reset. Each bound is then the reader's to tune.
|
|
26
|
+
* @default DEFAULT_ELEMENT_RANGES — C 1–30, H 1–30, O 0–6, N, S, F, Cl and Br 0–4
|
|
27
|
+
*/
|
|
28
|
+
ranges?: readonly ElementRange[];
|
|
29
|
+
/**
|
|
30
|
+
* How many formulas are listed, closest first.
|
|
31
|
+
* @default FORMULA_LOOKUP_LIMIT — 100
|
|
32
|
+
*/
|
|
33
|
+
limit?: number;
|
|
34
|
+
/**
|
|
35
|
+
* The mass in the box, in Da. With `onMassChange` the page owns it and the
|
|
36
|
+
* box shows this value; without, it is only the mass the box opens on.
|
|
37
|
+
* @default undefined — the box opens empty
|
|
38
|
+
*/
|
|
39
|
+
mass?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Called with every mass typed, and with `undefined` when the box is emptied
|
|
42
|
+
* or the panel reset. Passing it makes the mass the page's.
|
|
43
|
+
* @default undefined — the panel keeps the mass itself
|
|
44
|
+
*/
|
|
45
|
+
onMassChange?: (mass: number | undefined) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Class the panel carries, in addition to `formula-lookup`: where it sits —
|
|
48
|
+
* a column, a sticky rail, a shadow — is the page's.
|
|
49
|
+
* @default undefined
|
|
50
|
+
*/
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The molecular formulas that fit a monoisotopic mass: the reader types the
|
|
56
|
+
* mass of the neutral molecule, tunes how many atoms of each element a formula
|
|
57
|
+
* may hold, and reads the closest formulas with their distance in ppm. Each
|
|
58
|
+
* formula is copied by a click.
|
|
59
|
+
*
|
|
60
|
+
* The ranges belong to the question on screen, so a page showing several
|
|
61
|
+
* questions mounts the panel with a `key` per question.
|
|
62
|
+
* @param props - See {@link FormulaLookupProps}.
|
|
63
|
+
* @returns The panel.
|
|
64
|
+
*/
|
|
65
|
+
export function FormulaLookup(props: FormulaLookupProps): ReactElement {
|
|
66
|
+
const {
|
|
67
|
+
ranges: openingRanges = DEFAULT_ELEMENT_RANGES,
|
|
68
|
+
limit = FORMULA_LOOKUP_LIMIT,
|
|
69
|
+
mass: massProp,
|
|
70
|
+
onMassChange,
|
|
71
|
+
className,
|
|
72
|
+
} = props;
|
|
73
|
+
const t = useChromeT();
|
|
74
|
+
const [ownMass, setOwnMass] = useState(massProp);
|
|
75
|
+
const [ranges, setRanges] = useState(openingRanges);
|
|
76
|
+
const mass = onMassChange === undefined ? ownMass : massProp;
|
|
77
|
+
|
|
78
|
+
function changeMass(next: number | undefined): void {
|
|
79
|
+
if (onMassChange === undefined) setOwnMass(next);
|
|
80
|
+
else onMassChange(next);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function changeRange(
|
|
84
|
+
element: string,
|
|
85
|
+
bound: 'min' | 'max',
|
|
86
|
+
value: number,
|
|
87
|
+
): void {
|
|
88
|
+
setRanges((current) =>
|
|
89
|
+
current.map((range) =>
|
|
90
|
+
range.element === element ? { ...range, [bound]: value } : range,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function reset(): void {
|
|
96
|
+
changeMass(undefined);
|
|
97
|
+
setRanges(openingRanges);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const search = useMemo(
|
|
101
|
+
() => ({ mass, ranges, limit }),
|
|
102
|
+
[mass, ranges, limit],
|
|
103
|
+
);
|
|
104
|
+
const settled = useDebouncedValue(search, SEARCH_DELAY_MS);
|
|
105
|
+
const task = useCallback(() => searchFormulas(settled), [settled]);
|
|
106
|
+
const answer = useAsync(task);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<aside className={joinClassNames('formula-lookup', className)}>
|
|
110
|
+
<div className="formula-lookup__header">
|
|
111
|
+
<span>{t('formula.lookup.title')}</span>
|
|
112
|
+
<Button
|
|
113
|
+
size="small"
|
|
114
|
+
variant="minimal"
|
|
115
|
+
icon="reset"
|
|
116
|
+
className="no-print"
|
|
117
|
+
title={t('formula.lookup.reset')}
|
|
118
|
+
aria-label={t('formula.lookup.reset')}
|
|
119
|
+
onClick={reset}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="formula-lookup__body">
|
|
124
|
+
<label className="formula-lookup__field">
|
|
125
|
+
<span>{t('formula.lookup.mass')}</span>
|
|
126
|
+
<NumberInput
|
|
127
|
+
size="small"
|
|
128
|
+
buttons={false}
|
|
129
|
+
fill
|
|
130
|
+
allowEmpty
|
|
131
|
+
min={0}
|
|
132
|
+
value={mass}
|
|
133
|
+
placeholder={t('formula.lookup.massPlaceholder')}
|
|
134
|
+
onChange={changeMass}
|
|
135
|
+
/>
|
|
136
|
+
</label>
|
|
137
|
+
<span className="formula-lookup__hint">
|
|
138
|
+
{t('formula.lookup.neutral')}
|
|
139
|
+
</span>
|
|
140
|
+
<ElementRangeFields ranges={ranges} onChange={changeRange} />
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<FormulaLookupResults
|
|
144
|
+
mass={mass}
|
|
145
|
+
answer={answer}
|
|
146
|
+
waiting={settled !== search || answer.pending}
|
|
147
|
+
/>
|
|
148
|
+
</aside>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* How long the inputs stay still before the formulas are searched again: a
|
|
154
|
+
* mass is typed a digit at a time, and only the last one is worth a search.
|
|
155
|
+
*/
|
|
156
|
+
const SEARCH_DELAY_MS = 250;
|
|
157
|
+
|
|
158
|
+
const NO_CANDIDATES: FormulaCandidates = { candidates: [], total: 0 };
|
|
159
|
+
|
|
160
|
+
interface Search {
|
|
161
|
+
mass: number | undefined;
|
|
162
|
+
ranges: readonly ElementRange[];
|
|
163
|
+
limit: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The formulas one search finds, or `null` when it gave up: ranges wide
|
|
168
|
+
* enough to exhaust the enumeration are the one way it fails.
|
|
169
|
+
* @param search - The mass, ranges and limit to search.
|
|
170
|
+
* @returns The formulas found.
|
|
171
|
+
*/
|
|
172
|
+
async function searchFormulas(
|
|
173
|
+
search: Search,
|
|
174
|
+
): Promise<FormulaCandidates | null> {
|
|
175
|
+
if (search.mass === undefined) return NO_CANDIDATES;
|
|
176
|
+
try {
|
|
177
|
+
return await findFormulaCandidates(search.mass, {
|
|
178
|
+
ranges: search.ranges,
|
|
179
|
+
limit: search.limit,
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
import { formatDecimal, formatInteger } from '../../format/core/numbers.ts';
|
|
4
|
+
import type { AsyncResult } from '../../hooks/ui/useAsync.ts';
|
|
5
|
+
import { useChromeT } from '../../i18n/ui/useT.ts';
|
|
6
|
+
import type { FormulaCandidates } from '../core/formulaCandidates.ts';
|
|
7
|
+
|
|
8
|
+
import { CopyableFormula } from './CopyableFormula.tsx';
|
|
9
|
+
|
|
10
|
+
/** What {@link FormulaLookupResults} lists. */
|
|
11
|
+
export interface FormulaLookupResultsProps {
|
|
12
|
+
/** The mass in the box, `undefined` while it is empty. */
|
|
13
|
+
mass: number | undefined;
|
|
14
|
+
/** The latest search: its formulas, or `null` when it gave up. */
|
|
15
|
+
answer: AsyncResult<FormulaCandidates | null>;
|
|
16
|
+
/** Whether a newer search is still to come, so an empty answer is stale. */
|
|
17
|
+
waiting: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The formulas a lookup found, closest first, each with its distance in ppm
|
|
22
|
+
* and copied by a click; or the sentence saying why there are none.
|
|
23
|
+
* @param props - See {@link FormulaLookupResultsProps}.
|
|
24
|
+
* @returns The results.
|
|
25
|
+
*/
|
|
26
|
+
export function FormulaLookupResults(
|
|
27
|
+
props: FormulaLookupResultsProps,
|
|
28
|
+
): ReactElement {
|
|
29
|
+
const { mass, answer, waiting } = props;
|
|
30
|
+
const t = useChromeT();
|
|
31
|
+
const found = answer.data;
|
|
32
|
+
|
|
33
|
+
if (mass === undefined) return hintOnly(t('formula.lookup.typeMass'));
|
|
34
|
+
if (found === null) return hintOnly(t('formula.lookup.tooWide'));
|
|
35
|
+
// A search still to come is not "nothing fits": the reader is mid-number.
|
|
36
|
+
if (found === undefined || (found.candidates.length === 0 && waiting)) {
|
|
37
|
+
return <div className="formula-lookup__results" />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { candidates, total } = found;
|
|
41
|
+
const count = formatInteger(candidates.length);
|
|
42
|
+
let heading: string = t('formula.lookup.countMany', { count });
|
|
43
|
+
if (candidates.length < total) {
|
|
44
|
+
heading = t('formula.lookup.closest', {
|
|
45
|
+
count,
|
|
46
|
+
total: formatInteger(total),
|
|
47
|
+
});
|
|
48
|
+
} else if (candidates.length === 1) {
|
|
49
|
+
heading = t('formula.lookup.countOne', { count });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="formula-lookup__results">
|
|
54
|
+
<span className="formula-lookup__count">{heading}</span>
|
|
55
|
+
{candidates.length === 0 ? (
|
|
56
|
+
<span className="formula-lookup__hint">
|
|
57
|
+
{t('formula.lookup.nothingFits')}
|
|
58
|
+
</span>
|
|
59
|
+
) : (
|
|
60
|
+
<ul className="formula-lookup__list">
|
|
61
|
+
{candidates.map((candidate) => (
|
|
62
|
+
<li
|
|
63
|
+
key={candidate.mf}
|
|
64
|
+
title={t('formula.lookup.detail', {
|
|
65
|
+
mass: formatDecimal(candidate.em, 4),
|
|
66
|
+
unsaturation: formatInteger(candidate.unsaturation),
|
|
67
|
+
})}
|
|
68
|
+
>
|
|
69
|
+
<CopyableFormula formula={candidate.mf} />
|
|
70
|
+
<span className="formula-lookup__ppm">
|
|
71
|
+
{t('formula.lookup.ppm', {
|
|
72
|
+
value: formatDecimal(candidate.ppm, 1),
|
|
73
|
+
})}
|
|
74
|
+
</span>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ul>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hintOnly(hint: string): ReactElement {
|
|
84
|
+
return (
|
|
85
|
+
<div className="formula-lookup__results">
|
|
86
|
+
<span className="formula-lookup__hint">{hint}</span>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
package/src/formula/ui/index.ts
CHANGED
|
@@ -2,5 +2,7 @@ export type { CopyableFormulaProps } from './CopyableFormula.tsx';
|
|
|
2
2
|
export { CopyableFormula } from './CopyableFormula.tsx';
|
|
3
3
|
export type { FormulaInputProps } from './FormulaInput.tsx';
|
|
4
4
|
export { FormulaInput } from './FormulaInput.tsx';
|
|
5
|
+
export type { FormulaLookupProps } from './FormulaLookup.tsx';
|
|
6
|
+
export { FormulaLookup } from './FormulaLookup.tsx';
|
|
5
7
|
export type { QuestionFormulaProps } from './QuestionFormula.tsx';
|
|
6
8
|
export { QuestionFormula } from './QuestionFormula.tsx';
|
package/src/locales/de.json
CHANGED
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"site.regexp.tagline": "Reguläre Ausdrücke in einer interaktiven Spielwiese lernen.",
|
|
104
104
|
"site.pdb.tagline": "Ein schneller Blick auf jeden Eintrag der Protein Data Bank.",
|
|
105
105
|
"site.elucidation.tagline": "Eine Struktur aus einem 1H-NMR-Spektrum und einer Formel.",
|
|
106
|
+
"site.spectroscopy.tagline": "Eine Struktur aus ihren IR-, Massen- und NMR-Spektren bestimmen.",
|
|
106
107
|
"site.equilibrium.tagline": "Chemische Gleichgewichte: pH, Speziation und Titrationskurven.",
|
|
107
108
|
"site.polycarp.tagline": "Die Mikrostruktur einer radikalischen Copolymerisation vorhersagen.",
|
|
108
109
|
"site.3d.tagline": "Konformere in 3D aus einer Struktur, die Sie zeichnen.",
|
|
@@ -345,6 +346,21 @@
|
|
|
345
346
|
"formula.wholeCounts": "„{formula}“ gibt einem Atom die Anzahl {count}; Anzahlen sind ganze Zahlen.",
|
|
346
347
|
"formula.unknownNotation": "„{formula}“ verwendet eine Schreibweise, die dieses Werkzeug nicht zählt.",
|
|
347
348
|
"formula.unreadable": "„{formula}“ ist keine Formel, die dieses Werkzeug lesen kann ({reason}).",
|
|
349
|
+
"formula.lookup.title": "Formelsuche",
|
|
350
|
+
"formula.lookup.reset": "Masse und Elementbereiche zurücksetzen",
|
|
351
|
+
"formula.lookup.mass": "Monoisotopische Masse",
|
|
352
|
+
"formula.lookup.massPlaceholder": "z. B. 128.0626",
|
|
353
|
+
"formula.lookup.neutral": "Die des neutralen Moleküls: Ziehen Sie die Protonen ab, die das Spektrum hinzugefügt hat.",
|
|
354
|
+
"formula.lookup.fewest": "Mindestzahl der {element}-Atome",
|
|
355
|
+
"formula.lookup.most": "Höchstzahl der {element}-Atome",
|
|
356
|
+
"formula.lookup.typeMass": "Geben Sie eine Masse ein, um die Formeln aufzulisten, die diese Bereiche zulassen.",
|
|
357
|
+
"formula.lookup.closest": "Die {count} nächsten von {total} Formeln",
|
|
358
|
+
"formula.lookup.countOne": "{count} Formel",
|
|
359
|
+
"formula.lookup.countMany": "{count} Formeln",
|
|
360
|
+
"formula.lookup.nothingFits": "Keine Formel passt. Erweitern Sie einen Bereich oder prüfen Sie die Masse.",
|
|
361
|
+
"formula.lookup.tooWide": "Diese Bereiche lassen zu viele Formeln für die Suche zu. Schränken Sie einen ein.",
|
|
362
|
+
"formula.lookup.ppm": "{value} ppm",
|
|
363
|
+
"formula.lookup.detail": "{mass} Da, Unsättigungsgrad {unsaturation}",
|
|
348
364
|
"slides.exit": "Beenden",
|
|
349
365
|
"slides.previousSlide": "Vorige Folie",
|
|
350
366
|
"slides.nextSlide": "Nächste Folie",
|
package/src/locales/en.json
CHANGED
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"site.regexp.tagline": "Learn regular expressions in a live playground.",
|
|
104
104
|
"site.pdb.tagline": "A fast look at any Protein Data Bank entry.",
|
|
105
105
|
"site.elucidation.tagline": "A structure from a 1H NMR spectrum and a formula.",
|
|
106
|
+
"site.spectroscopy.tagline": "Find a structure from its IR, mass and NMR spectra.",
|
|
106
107
|
"site.equilibrium.tagline": "Chemical equilibria: pH, speciation and titration curves.",
|
|
107
108
|
"site.polycarp.tagline": "Predict the microstructure of a radical copolymerisation.",
|
|
108
109
|
"site.3d.tagline": "Conformers in 3D from a structure you draw.",
|
|
@@ -345,6 +346,21 @@
|
|
|
345
346
|
"formula.wholeCounts": "“{formula}” gives an atom a count of {count}; counts are whole numbers.",
|
|
346
347
|
"formula.unknownNotation": "“{formula}” uses a notation this tool does not count.",
|
|
347
348
|
"formula.unreadable": "“{formula}” is not a formula this tool can read ({reason}).",
|
|
349
|
+
"formula.lookup.title": "Formula lookup",
|
|
350
|
+
"formula.lookup.reset": "Reset the mass and the element ranges",
|
|
351
|
+
"formula.lookup.mass": "Monoisotopic mass",
|
|
352
|
+
"formula.lookup.massPlaceholder": "e.g. 128.0626",
|
|
353
|
+
"formula.lookup.neutral": "Of the neutral molecule: take the protons the spectrum added back off.",
|
|
354
|
+
"formula.lookup.fewest": "Fewest {element} atoms",
|
|
355
|
+
"formula.lookup.most": "Most {element} atoms",
|
|
356
|
+
"formula.lookup.typeMass": "Type a mass to list the formulas these ranges allow.",
|
|
357
|
+
"formula.lookup.closest": "The {count} closest of {total} formulas",
|
|
358
|
+
"formula.lookup.countOne": "{count} formula",
|
|
359
|
+
"formula.lookup.countMany": "{count} formulas",
|
|
360
|
+
"formula.lookup.nothingFits": "Nothing fits. Widen a range, or check the mass.",
|
|
361
|
+
"formula.lookup.tooWide": "These ranges allow too many formulas to search. Narrow one.",
|
|
362
|
+
"formula.lookup.ppm": "{value} ppm",
|
|
363
|
+
"formula.lookup.detail": "{mass} Da, unsaturation {unsaturation}",
|
|
348
364
|
"slides.exit": "Exit",
|
|
349
365
|
"slides.previousSlide": "Previous slide",
|
|
350
366
|
"slides.nextSlide": "Next slide",
|
package/src/locales/es.json
CHANGED
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"site.regexp.tagline": "Aprender expresiones regulares en un entorno interactivo.",
|
|
104
104
|
"site.pdb.tagline": "Una mirada rápida a cualquier entrada del Protein Data Bank.",
|
|
105
105
|
"site.elucidation.tagline": "Una estructura a partir de un espectro de RMN de 1H y una fórmula.",
|
|
106
|
+
"site.spectroscopy.tagline": "Encontrar una estructura a partir de sus espectros IR, de masas y de RMN.",
|
|
106
107
|
"site.equilibrium.tagline": "Equilibrios químicos: pH, especiación y curvas de valoración.",
|
|
107
108
|
"site.polycarp.tagline": "Predecir la microestructura de una copolimerización radicalaria.",
|
|
108
109
|
"site.3d.tagline": "Confórmeros en 3D a partir de una estructura que usted dibuja.",
|
|
@@ -345,6 +346,21 @@
|
|
|
345
346
|
"formula.wholeCounts": "«{formula}» da a un átomo la cantidad {count}; las cantidades son números enteros.",
|
|
346
347
|
"formula.unknownNotation": "«{formula}» usa una notación que esta herramienta no cuenta.",
|
|
347
348
|
"formula.unreadable": "«{formula}» no es una fórmula que esta herramienta sepa leer ({reason}).",
|
|
349
|
+
"formula.lookup.title": "Búsqueda de fórmulas",
|
|
350
|
+
"formula.lookup.reset": "Restablecer la masa y los intervalos de elementos",
|
|
351
|
+
"formula.lookup.mass": "Masa monoisotópica",
|
|
352
|
+
"formula.lookup.massPlaceholder": "p. ej. 128.0626",
|
|
353
|
+
"formula.lookup.neutral": "La de la molécula neutra: reste los protones que el espectro añadió.",
|
|
354
|
+
"formula.lookup.fewest": "Mínimo de átomos de {element}",
|
|
355
|
+
"formula.lookup.most": "Máximo de átomos de {element}",
|
|
356
|
+
"formula.lookup.typeMass": "Escriba una masa para listar las fórmulas que permiten estos intervalos.",
|
|
357
|
+
"formula.lookup.closest": "Las {count} más cercanas de {total} fórmulas",
|
|
358
|
+
"formula.lookup.countOne": "{count} fórmula",
|
|
359
|
+
"formula.lookup.countMany": "{count} fórmulas",
|
|
360
|
+
"formula.lookup.nothingFits": "Ninguna fórmula encaja. Amplíe un intervalo o compruebe la masa.",
|
|
361
|
+
"formula.lookup.tooWide": "Estos intervalos permiten demasiadas fórmulas para buscarlas. Reduzca uno.",
|
|
362
|
+
"formula.lookup.ppm": "{value} ppm",
|
|
363
|
+
"formula.lookup.detail": "{mass} Da, insaturación {unsaturation}",
|
|
348
364
|
"slides.exit": "Salir",
|
|
349
365
|
"slides.previousSlide": "Diapositiva anterior",
|
|
350
366
|
"slides.nextSlide": "Diapositiva siguiente",
|