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.
Files changed (98) hide show
  1. package/lib/download/core/copySvg.d.ts +12 -0
  2. package/lib/download/core/copySvg.d.ts.map +1 -0
  3. package/lib/download/core/copySvg.js +38 -0
  4. package/lib/download/core/copySvg.js.map +1 -0
  5. package/lib/download/core/index.d.ts +1 -0
  6. package/lib/download/core/index.d.ts.map +1 -1
  7. package/lib/download/core/index.js +1 -0
  8. package/lib/download/core/index.js.map +1 -1
  9. package/lib/ecosystem/core/sites.d.ts.map +1 -1
  10. package/lib/ecosystem/core/sites.js +26 -0
  11. package/lib/ecosystem/core/sites.js.map +1 -1
  12. package/lib/ecosystem/core/types.d.ts +1 -1
  13. package/lib/ecosystem/core/types.d.ts.map +1 -1
  14. package/lib/ecosystem/ui/glyphs.d.ts.map +1 -1
  15. package/lib/ecosystem/ui/glyphs.js +7 -0
  16. package/lib/ecosystem/ui/glyphs.js.map +1 -1
  17. package/lib/formula/core/elementRanges.d.ts +25 -0
  18. package/lib/formula/core/elementRanges.d.ts.map +1 -0
  19. package/lib/formula/core/elementRanges.js +35 -0
  20. package/lib/formula/core/elementRanges.js.map +1 -0
  21. package/lib/formula/core/formulaCandidates.d.ts +59 -0
  22. package/lib/formula/core/formulaCandidates.d.ts.map +1 -0
  23. package/lib/formula/core/formulaCandidates.js +50 -0
  24. package/lib/formula/core/formulaCandidates.js.map +1 -0
  25. package/lib/formula/core/index.d.ts +4 -0
  26. package/lib/formula/core/index.d.ts.map +1 -1
  27. package/lib/formula/core/index.js +2 -0
  28. package/lib/formula/core/index.js.map +1 -1
  29. package/lib/formula/ui/ElementRangeFields.d.ts +19 -0
  30. package/lib/formula/ui/ElementRangeFields.d.ts.map +1 -0
  31. package/lib/formula/ui/ElementRangeFields.js +23 -0
  32. package/lib/formula/ui/ElementRangeFields.js.map +1 -0
  33. package/lib/formula/ui/FormulaLookup.d.ts +47 -0
  34. package/lib/formula/ui/FormulaLookup.d.ts.map +1 -0
  35. package/lib/formula/ui/FormulaLookup.js +74 -0
  36. package/lib/formula/ui/FormulaLookup.js.map +1 -0
  37. package/lib/formula/ui/FormulaLookupResults.d.ts +20 -0
  38. package/lib/formula/ui/FormulaLookupResults.d.ts.map +1 -0
  39. package/lib/formula/ui/FormulaLookupResults.js +45 -0
  40. package/lib/formula/ui/FormulaLookupResults.js.map +1 -0
  41. package/lib/formula/ui/index.d.ts +2 -0
  42. package/lib/formula/ui/index.d.ts.map +1 -1
  43. package/lib/formula/ui/index.js +1 -0
  44. package/lib/formula/ui/index.js.map +1 -1
  45. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  46. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  47. package/lib/ir/core/bandAssignments.d.ts.map +1 -1
  48. package/lib/ir/core/bandAssignments.js +27 -0
  49. package/lib/ir/core/bandAssignments.js.map +1 -1
  50. package/lib/ir/ui/BandRow.d.ts +4 -4
  51. package/lib/ir/ui/BandRow.d.ts.map +1 -1
  52. package/lib/ir/ui/BandRow.js +41 -14
  53. package/lib/ir/ui/BandRow.js.map +1 -1
  54. package/lib/locales/de.json +18 -0
  55. package/lib/locales/en.json +18 -0
  56. package/lib/locales/es.json +18 -0
  57. package/lib/locales/fr.json +18 -0
  58. package/lib/locales/it.json +18 -0
  59. package/lib/structure/ui/CardStructure.d.ts +43 -0
  60. package/lib/structure/ui/CardStructure.d.ts.map +1 -0
  61. package/lib/structure/ui/CardStructure.js +28 -0
  62. package/lib/structure/ui/CardStructure.js.map +1 -0
  63. package/lib/structure/ui/StructurePicturePane.d.ts +1 -1
  64. package/lib/structure/ui/StructurePicturePane.d.ts.map +1 -1
  65. package/lib/structure/ui/StructurePicturePane.js +5 -2
  66. package/lib/structure/ui/StructurePicturePane.js.map +1 -1
  67. package/lib/structure/ui/index.d.ts +2 -0
  68. package/lib/structure/ui/index.d.ts.map +1 -1
  69. package/lib/structure/ui/index.js +1 -0
  70. package/lib/structure/ui/index.js.map +1 -1
  71. package/lib/structure/ui/useToolbarFloor.d.ts.map +1 -1
  72. package/lib/structure/ui/useToolbarFloor.js +20 -10
  73. package/lib/structure/ui/useToolbarFloor.js.map +1 -1
  74. package/package.json +4 -3
  75. package/src/download/core/copySvg.ts +40 -0
  76. package/src/download/core/index.ts +1 -0
  77. package/src/ecosystem/core/sites.ts +26 -0
  78. package/src/ecosystem/core/types.ts +3 -1
  79. package/src/ecosystem/ui/glyphs.tsx +46 -0
  80. package/src/formula/core/elementRanges.ts +44 -0
  81. package/src/formula/core/formulaCandidates.ts +109 -0
  82. package/src/formula/core/index.ts +15 -0
  83. package/src/formula/ui/ElementRangeFields.tsx +65 -0
  84. package/src/formula/ui/FormulaLookup.tsx +184 -0
  85. package/src/formula/ui/FormulaLookupResults.tsx +89 -0
  86. package/src/formula/ui/index.ts +2 -0
  87. package/src/ir/core/bandAssignments.ts +27 -0
  88. package/src/ir/ui/BandRow.tsx +84 -26
  89. package/src/locales/de.json +18 -0
  90. package/src/locales/en.json +18 -0
  91. package/src/locales/es.json +18 -0
  92. package/src/locales/fr.json +18 -0
  93. package/src/locales/it.json +18 -0
  94. package/src/structure/ui/CardStructure.tsx +76 -0
  95. package/src/structure/ui/StructurePicturePane.tsx +14 -1
  96. package/src/structure/ui/index.ts +2 -0
  97. package/src/structure/ui/useToolbarFloor.ts +19 -9
  98. 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
+ }
@@ -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';