react-cheminfo 0.74.0 → 0.76.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/download/core/copySvg.d.ts +12 -0
- package/lib/download/core/copySvg.d.ts.map +1 -0
- package/lib/download/core/copySvg.js +38 -0
- package/lib/download/core/copySvg.js.map +1 -0
- package/lib/download/core/index.d.ts +1 -0
- package/lib/download/core/index.d.ts.map +1 -1
- package/lib/download/core/index.js +1 -0
- package/lib/download/core/index.js.map +1 -1
- 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/ir/core/bandAssignments.d.ts.map +1 -1
- package/lib/ir/core/bandAssignments.js +27 -0
- package/lib/ir/core/bandAssignments.js.map +1 -1
- package/lib/ir/ui/BandRow.d.ts +4 -4
- package/lib/ir/ui/BandRow.d.ts.map +1 -1
- package/lib/ir/ui/BandRow.js +41 -14
- package/lib/ir/ui/BandRow.js.map +1 -1
- package/lib/locales/de.json +18 -0
- package/lib/locales/en.json +18 -0
- package/lib/locales/es.json +18 -0
- package/lib/locales/fr.json +18 -0
- package/lib/locales/it.json +18 -0
- package/lib/structure/ui/CardStructure.d.ts +43 -0
- package/lib/structure/ui/CardStructure.d.ts.map +1 -0
- package/lib/structure/ui/CardStructure.js +28 -0
- package/lib/structure/ui/CardStructure.js.map +1 -0
- package/lib/structure/ui/StructurePicturePane.d.ts +1 -1
- package/lib/structure/ui/StructurePicturePane.d.ts.map +1 -1
- package/lib/structure/ui/StructurePicturePane.js +5 -2
- package/lib/structure/ui/StructurePicturePane.js.map +1 -1
- package/lib/structure/ui/index.d.ts +2 -0
- package/lib/structure/ui/index.d.ts.map +1 -1
- package/lib/structure/ui/index.js +1 -0
- package/lib/structure/ui/index.js.map +1 -1
- 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/download/core/copySvg.ts +40 -0
- package/src/download/core/index.ts +1 -0
- 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/ir/core/bandAssignments.ts +27 -0
- package/src/ir/ui/BandRow.tsx +84 -26
- package/src/locales/de.json +18 -0
- package/src/locales/en.json +18 -0
- package/src/locales/es.json +18 -0
- package/src/locales/fr.json +18 -0
- package/src/locales/it.json +18 -0
- package/src/structure/ui/CardStructure.tsx +76 -0
- package/src/structure/ui/StructurePicturePane.tsx +14 -1
- package/src/structure/ui/index.ts +2 -0
- package/src/structure/ui/useToolbarFloor.ts +19 -9
- package/styles/chrome.css +136 -3
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { writeClipboardItem } from '../../clipboard/core/clipboardItem.ts';
|
|
2
|
+
import { writeToClipboard } from '../../clipboard/core/writeToClipboard.ts';
|
|
3
|
+
|
|
4
|
+
/** The type a picture is put on the clipboard as: no `charset`, which it refuses. */
|
|
5
|
+
const SVG_CLIPBOARD_TYPE = 'image/svg+xml';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Put an SVG document on the clipboard, without ever throwing at the caller.
|
|
9
|
+
*
|
|
10
|
+
* Where the browser takes an SVG picture, the document goes on as one and as
|
|
11
|
+
* its text, in one item: a slide or a vector editor pastes the drawing, a text
|
|
12
|
+
* editor the markup. Elsewhere — Firefox and Safari take no SVG picture — the
|
|
13
|
+
* text alone, which a vector editor still pastes as a drawing.
|
|
14
|
+
* @param markup - The SVG document.
|
|
15
|
+
* @returns Whether the clipboard now holds it, at least as text.
|
|
16
|
+
*/
|
|
17
|
+
export async function copySvg(markup: string): Promise<boolean> {
|
|
18
|
+
if (takesSvgPicture()) {
|
|
19
|
+
const written = await writeClipboardItem({
|
|
20
|
+
[SVG_CLIPBOARD_TYPE]: new Blob([markup], { type: SVG_CLIPBOARD_TYPE }),
|
|
21
|
+
'text/plain': new Blob([markup], { type: 'text/plain' }),
|
|
22
|
+
});
|
|
23
|
+
if (written) return true;
|
|
24
|
+
}
|
|
25
|
+
return writeToClipboard(markup);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether the clipboard says it takes an SVG picture. Asked before writing:
|
|
30
|
+
* a write it refuses would spend the click, and the text after it would be
|
|
31
|
+
* refused too in Safari.
|
|
32
|
+
* @returns Whether `ClipboardItem.supports` names the SVG type.
|
|
33
|
+
*/
|
|
34
|
+
function takesSvgPicture(): boolean {
|
|
35
|
+
if (typeof ClipboardItem === 'undefined') return false;
|
|
36
|
+
const item = ClipboardItem as { supports?: (type: string) => boolean };
|
|
37
|
+
return (
|
|
38
|
+
typeof item.supports === 'function' && item.supports(SVG_CLIPBOARD_TYPE)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { FigureNotice } from './copyFigure.ts';
|
|
2
2
|
export { copyFigure } from './copyFigure.ts';
|
|
3
|
+
export { copySvg } from './copySvg.ts';
|
|
3
4
|
export { cssTokenNames, resolveCssTokens } from './cssTokens.ts';
|
|
4
5
|
export { downloadBlob } from './downloadBlob.ts';
|
|
5
6
|
export type { DownloadFigureOptions, FigureFormat } from './downloadFigure.ts';
|
|
@@ -200,6 +200,20 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
|
|
|
200
200
|
brandAlt: '#b45309',
|
|
201
201
|
mark: { plate: '#7e22ce', accent: '#f59e0b' },
|
|
202
202
|
},
|
|
203
|
+
{
|
|
204
|
+
id: 'spectroscopy',
|
|
205
|
+
name: { lead: 'spectroscopy', alt: 'cheminfo', dot: true },
|
|
206
|
+
host: 'spectroscopy.cheminfo.org',
|
|
207
|
+
tagline: 'Find a structure from its IR, mass and NMR spectra.',
|
|
208
|
+
group: 'spectra',
|
|
209
|
+
// The violet end of the spectrum: the greens a spectrum would suggest are
|
|
210
|
+
// already smiles', equilibrium's and openbabel's.
|
|
211
|
+
brand: '#744d9f',
|
|
212
|
+
// The lime the mark's ring is drawn in sits at 1.5:1 on white, so the
|
|
213
|
+
// second half of the wordmark takes this darkened form, at 5.0:1.
|
|
214
|
+
brandAlt: '#4d7c0f',
|
|
215
|
+
mark: { plate: '#744d9f', accent: '#a3e635' },
|
|
216
|
+
},
|
|
203
217
|
{
|
|
204
218
|
id: 'equilibrium',
|
|
205
219
|
name: { lead: 'Equi', alt: 'Librium' },
|
|
@@ -324,6 +338,18 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
|
|
|
324
338
|
brandAlt: '#1d4ed8',
|
|
325
339
|
mark: { plate: '#c8460d', accent: '#1d4ed8' },
|
|
326
340
|
},
|
|
341
|
+
{
|
|
342
|
+
id: 'vibrations',
|
|
343
|
+
name: { lead: 'vibrations', alt: 'cheminfo', dot: true },
|
|
344
|
+
host: 'vibrations.cheminfo.org',
|
|
345
|
+
tagline: 'IR and Raman spectra computed in the browser.',
|
|
346
|
+
group: 'spectra',
|
|
347
|
+
brand: '#0072b2',
|
|
348
|
+
// The amber of the mark sits at 2.3:1 on white, so the wordmark sets its
|
|
349
|
+
// second half in the darker amber, at 4.9:1.
|
|
350
|
+
brandAlt: '#a16207',
|
|
351
|
+
mark: { plate: '#0072b2', accent: '#e69f00' },
|
|
352
|
+
},
|
|
327
353
|
];
|
|
328
354
|
|
|
329
355
|
/** How a link to a site is written, beyond the site itself. */
|
|
@@ -19,6 +19,7 @@ export type SiteId =
|
|
|
19
19
|
| 'regexp'
|
|
20
20
|
| 'pdb'
|
|
21
21
|
| 'elucidation'
|
|
22
|
+
| 'spectroscopy'
|
|
22
23
|
| 'equilibrium'
|
|
23
24
|
| 'polycarp'
|
|
24
25
|
| '3d'
|
|
@@ -33,7 +34,8 @@ export type SiteId =
|
|
|
33
34
|
| 'slides'
|
|
34
35
|
| 'chembio'
|
|
35
36
|
| 'wikidata'
|
|
36
|
-
| 'mz'
|
|
37
|
+
| 'mz'
|
|
38
|
+
| 'vibrations';
|
|
37
39
|
|
|
38
40
|
/** A site's name, split the way its own wordmark splits it. */
|
|
39
41
|
export interface SiteName {
|
|
@@ -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';
|