react-cheminfo 0.73.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.
Files changed (109) hide show
  1. package/lib/ecosystem/core/sites.d.ts.map +1 -1
  2. package/lib/ecosystem/core/sites.js +26 -0
  3. package/lib/ecosystem/core/sites.js.map +1 -1
  4. package/lib/ecosystem/core/types.d.ts +1 -1
  5. package/lib/ecosystem/core/types.d.ts.map +1 -1
  6. package/lib/ecosystem/ui/glyphs.d.ts.map +1 -1
  7. package/lib/ecosystem/ui/glyphs.js +7 -0
  8. package/lib/ecosystem/ui/glyphs.js.map +1 -1
  9. package/lib/formula/core/elementRanges.d.ts +25 -0
  10. package/lib/formula/core/elementRanges.d.ts.map +1 -0
  11. package/lib/formula/core/elementRanges.js +35 -0
  12. package/lib/formula/core/elementRanges.js.map +1 -0
  13. package/lib/formula/core/formulaCandidates.d.ts +59 -0
  14. package/lib/formula/core/formulaCandidates.d.ts.map +1 -0
  15. package/lib/formula/core/formulaCandidates.js +50 -0
  16. package/lib/formula/core/formulaCandidates.js.map +1 -0
  17. package/lib/formula/core/index.d.ts +4 -0
  18. package/lib/formula/core/index.d.ts.map +1 -1
  19. package/lib/formula/core/index.js +2 -0
  20. package/lib/formula/core/index.js.map +1 -1
  21. package/lib/formula/ui/ElementRangeFields.d.ts +19 -0
  22. package/lib/formula/ui/ElementRangeFields.d.ts.map +1 -0
  23. package/lib/formula/ui/ElementRangeFields.js +23 -0
  24. package/lib/formula/ui/ElementRangeFields.js.map +1 -0
  25. package/lib/formula/ui/FormulaLookup.d.ts +47 -0
  26. package/lib/formula/ui/FormulaLookup.d.ts.map +1 -0
  27. package/lib/formula/ui/FormulaLookup.js +74 -0
  28. package/lib/formula/ui/FormulaLookup.js.map +1 -0
  29. package/lib/formula/ui/FormulaLookupResults.d.ts +20 -0
  30. package/lib/formula/ui/FormulaLookupResults.d.ts.map +1 -0
  31. package/lib/formula/ui/FormulaLookupResults.js +45 -0
  32. package/lib/formula/ui/FormulaLookupResults.js.map +1 -0
  33. package/lib/formula/ui/index.d.ts +2 -0
  34. package/lib/formula/ui/index.d.ts.map +1 -1
  35. package/lib/formula/ui/index.js +1 -0
  36. package/lib/formula/ui/index.js.map +1 -1
  37. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  38. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  39. package/lib/locales/de.json +38 -0
  40. package/lib/locales/en.json +38 -0
  41. package/lib/locales/es.json +38 -0
  42. package/lib/locales/fr.json +38 -0
  43. package/lib/locales/it.json +38 -0
  44. package/lib/structure/core/fragmentIdCode.d.ts +15 -0
  45. package/lib/structure/core/fragmentIdCode.d.ts.map +1 -0
  46. package/lib/structure/core/fragmentIdCode.js +25 -0
  47. package/lib/structure/core/fragmentIdCode.js.map +1 -0
  48. package/lib/structure/core/index.d.ts +2 -0
  49. package/lib/structure/core/index.d.ts.map +1 -1
  50. package/lib/structure/core/index.js +1 -0
  51. package/lib/structure/core/index.js.map +1 -1
  52. package/lib/structure/core/structureSearch.d.ts +44 -0
  53. package/lib/structure/core/structureSearch.d.ts.map +1 -0
  54. package/lib/structure/core/structureSearch.js +31 -0
  55. package/lib/structure/core/structureSearch.js.map +1 -0
  56. package/lib/structure/core/substructureMatch.d.ts +33 -0
  57. package/lib/structure/core/substructureMatch.d.ts.map +1 -0
  58. package/lib/structure/core/substructureMatch.js +73 -0
  59. package/lib/structure/core/substructureMatch.js.map +1 -0
  60. package/lib/structure/ui/Structure.d.ts +18 -0
  61. package/lib/structure/ui/Structure.d.ts.map +1 -1
  62. package/lib/structure/ui/Structure.js +2 -2
  63. package/lib/structure/ui/Structure.js.map +1 -1
  64. package/lib/structure/ui/StructureSearchDialog.d.ts +55 -0
  65. package/lib/structure/ui/StructureSearchDialog.d.ts.map +1 -0
  66. package/lib/structure/ui/StructureSearchDialog.js +77 -0
  67. package/lib/structure/ui/StructureSearchDialog.js.map +1 -0
  68. package/lib/structure/ui/StructureSvg.d.ts +9 -0
  69. package/lib/structure/ui/StructureSvg.d.ts.map +1 -1
  70. package/lib/structure/ui/StructureSvg.js +7 -2
  71. package/lib/structure/ui/StructureSvg.js.map +1 -1
  72. package/lib/structure/ui/index.d.ts +4 -0
  73. package/lib/structure/ui/index.d.ts.map +1 -1
  74. package/lib/structure/ui/index.js +2 -0
  75. package/lib/structure/ui/index.js.map +1 -1
  76. package/lib/structure/ui/useStructureSearchModes.d.ts +18 -0
  77. package/lib/structure/ui/useStructureSearchModes.d.ts.map +1 -0
  78. package/lib/structure/ui/useStructureSearchModes.js +36 -0
  79. package/lib/structure/ui/useStructureSearchModes.js.map +1 -0
  80. package/lib/structure/ui/useToolbarFloor.d.ts.map +1 -1
  81. package/lib/structure/ui/useToolbarFloor.js +20 -10
  82. package/lib/structure/ui/useToolbarFloor.js.map +1 -1
  83. package/package.json +4 -3
  84. package/src/ecosystem/core/sites.ts +26 -0
  85. package/src/ecosystem/core/types.ts +3 -1
  86. package/src/ecosystem/ui/glyphs.tsx +46 -0
  87. package/src/formula/core/elementRanges.ts +44 -0
  88. package/src/formula/core/formulaCandidates.ts +109 -0
  89. package/src/formula/core/index.ts +15 -0
  90. package/src/formula/ui/ElementRangeFields.tsx +65 -0
  91. package/src/formula/ui/FormulaLookup.tsx +184 -0
  92. package/src/formula/ui/FormulaLookupResults.tsx +89 -0
  93. package/src/formula/ui/index.ts +2 -0
  94. package/src/locales/de.json +38 -0
  95. package/src/locales/en.json +38 -0
  96. package/src/locales/es.json +38 -0
  97. package/src/locales/fr.json +38 -0
  98. package/src/locales/it.json +38 -0
  99. package/src/structure/core/fragmentIdCode.ts +25 -0
  100. package/src/structure/core/index.ts +10 -0
  101. package/src/structure/core/structureSearch.ts +68 -0
  102. package/src/structure/core/substructureMatch.ts +85 -0
  103. package/src/structure/ui/Structure.tsx +24 -0
  104. package/src/structure/ui/StructureSearchDialog.tsx +218 -0
  105. package/src/structure/ui/StructureSvg.tsx +35 -0
  106. package/src/structure/ui/index.ts +4 -0
  107. package/src/structure/ui/useStructureSearchModes.ts +51 -0
  108. package/src/structure/ui/useToolbarFloor.ts +19 -9
  109. package/styles/chrome.css +164 -0
@@ -0,0 +1,36 @@
1
+ import { useChromeT } from "../../i18n/ui/useT.js";
2
+ import { STRUCTURE_SEARCH_MODES } from "../core/structureSearch.js";
3
+ const MODE_KEYS = {
4
+ substructure: [
5
+ 'structure.search.substructure',
6
+ 'structure.search.substructureHelp',
7
+ ],
8
+ similarity: [
9
+ 'structure.search.similarity',
10
+ 'structure.search.similarityHelp',
11
+ ],
12
+ exact: ['structure.search.exact', 'structure.search.exactHelp'],
13
+ exactNoStereo: [
14
+ 'structure.search.exactNoStereo',
15
+ 'structure.search.exactNoStereoHelp',
16
+ ],
17
+ exactNoStereoTautomer: [
18
+ 'structure.search.exactNoStereoTautomer',
19
+ 'structure.search.exactNoStereoTautomerHelp',
20
+ ],
21
+ };
22
+ /**
23
+ * The modes in the reader's language, for a site that shows the mode in force
24
+ * beside the drawing.
25
+ * @param modes - The modes, in order.
26
+ * @returns Each mode with its label and its one-line help.
27
+ */
28
+ export function useStructureSearchModes(modes = STRUCTURE_SEARCH_MODES) {
29
+ const t = useChromeT();
30
+ return modes.map((mode) => ({
31
+ mode,
32
+ label: t(MODE_KEYS[mode][0]),
33
+ help: t(MODE_KEYS[mode][1]),
34
+ }));
35
+ }
36
+ //# sourceMappingURL=useStructureSearchModes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useStructureSearchModes.js","sourceRoot":"","sources":["../../../src/structure/ui/useStructureSearchModes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEnD,OAAO,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAYpE,MAAM,SAAS,GAAwD;IACrE,YAAY,EAAE;QACZ,+BAA+B;QAC/B,mCAAmC;KACpC;IACD,UAAU,EAAE;QACV,6BAA6B;QAC7B,iCAAiC;KAClC;IACD,KAAK,EAAE,CAAC,wBAAwB,EAAE,4BAA4B,CAAC;IAC/D,aAAa,EAAE;QACb,gCAAgC;QAChC,oCAAoC;KACrC;IACD,qBAAqB,EAAE;QACrB,wCAAwC;QACxC,4CAA4C;KAC7C;CACF,CAAC;AAEF;;;;;GAKG;AACH,MAAM,UAAU,uBAAuB,CACrC,QAAwC,sBAAsB;IAE9D,MAAM,CAAC,GAAG,UAAU,EAAE,CAAC;IACvB,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAC1B,IAAI;QACJ,KAAK,EAAE,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAC5B,IAAI,EAAE,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;KAC5B,CAAC,CAAC,CAAC;AACN,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"useToolbarFloor.d.ts","sourceRoot":"","sources":["../../../src/structure/ui/useToolbarFloor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAOvC,0CAA0C;AAC1C,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,OAAO,GAAE,mBAAwB,GAChC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAyBlC"}
1
+ {"version":3,"file":"useToolbarFloor.d.ts","sourceRoot":"","sources":["../../../src/structure/ui/useToolbarFloor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAOvC,0CAA0C;AAC1C,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,OAAO,GAAE,mBAAwB,GAChC,SAAS,CAAC,cAAc,GAAG,IAAI,CAAC,CAmClC"}
@@ -18,19 +18,29 @@ export function useToolbarFloor(options = {}) {
18
18
  if (container === null)
19
19
  return;
20
20
  return watchToolbar(container, (toolbar) => {
21
+ let frame = 0;
21
22
  const size = new ResizeObserver(() => {
22
- // offsetHeight, because a dialog opens under a scaling transform and a
23
- // measured rectangle would be that of the half drawn toolbar.
24
- const height = toolbar.offsetHeight;
25
- // Zero is a toolbar being torn down or not yet laid out, never a
26
- // toolbar that needs no room: writing the fallback for it would undo a
27
- // correct floor and leave the palette cut off.
28
- if (height === 0)
29
- return;
30
- container.style.minHeight = `${toolbarFloorHeight(height, minHeight)}px`;
23
+ // Written on the next frame: growing the box from inside this callback
24
+ // resizes the editor element above the toolbar, whose own observer the
25
+ // browser then drops with a "ResizeObserver loop" error.
26
+ cancelAnimationFrame(frame);
27
+ frame = requestAnimationFrame(() => {
28
+ // offsetHeight, because a dialog opens under a scaling transform and
29
+ // a measured rectangle would be that of the half drawn toolbar.
30
+ const height = toolbar.offsetHeight;
31
+ // Zero is a toolbar being torn down or not yet laid out, never a
32
+ // toolbar that needs no room: writing the fallback for it would undo
33
+ // a correct floor and leave the palette cut off.
34
+ if (height === 0)
35
+ return;
36
+ container.style.minHeight = `${toolbarFloorHeight(height, minHeight)}px`;
37
+ });
31
38
  });
32
39
  size.observe(toolbar);
33
- return () => size.disconnect();
40
+ return () => {
41
+ cancelAnimationFrame(frame);
42
+ size.disconnect();
43
+ };
34
44
  });
35
45
  }, [minHeight, revision]);
36
46
  return containerRef;
@@ -1 +1 @@
1
- {"version":3,"file":"useToolbarFloor.js","sourceRoot":"","sources":["../../../src/structure/ui/useToolbarFloor.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAEhD,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAkBjD;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAC7B,UAA+B,EAAE;IAEjC,MAAM,EAAE,SAAS,GAAG,GAAG,EAAE,QAAQ,GAAG,CAAC,EAAE,GAAG,OAAO,CAAC;IAClD,MAAM,YAAY,GAAG,MAAM,CAAiB,IAAI,CAAC,CAAC;IAElD,eAAe,CAAC,GAAG,EAAE;QACnB,MAAM,SAAS,GAAG,YAAY,CAAC,OAAO,CAAC;QACvC,IAAI,SAAS,KAAK,IAAI;YAAE,OAAO;QAE/B,OAAO,YAAY,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE;YACzC,MAAM,IAAI,GAAG,IAAI,cAAc,CAAC,GAAG,EAAE;gBACnC,uEAAuE;gBACvE,8DAA8D;gBAC9D,MAAM,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;gBACpC,iEAAiE;gBACjE,uEAAuE;gBACvE,+CAA+C;gBAC/C,IAAI,MAAM,KAAK,CAAC;oBAAE,OAAO;gBACzB,SAAS,CAAC,KAAK,CAAC,SAAS,GAAG,GAAG,kBAAkB,CAAC,MAAM,EAAE,SAAS,CAAC,IAAI,CAAC;YAC3E,CAAC,CAAC,CAAC;YACH,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YACtB,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;QACjC,CAAC,CAAC,CAAC;IACL,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC;IAE1B,OAAO,YAAY,CAAC;AACtB,CAAC"}
1
+ {"version":3,"file":"useToolbarFloor.js","sourceRoot":"","sources":["../../../src/structure/ui/useToolbarFloor.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAEhD,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAkBjD;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAC7B,UAA+B,EAAE;IAEjC,MAAM,EAAE,SAAS,GAAG,GAAG,EAAE,QAAQ,GAAG,CAAC,EAAE,GAAG,OAAO,CAAC;IAClD,MAAM,YAAY,GAAG,MAAM,CAAiB,IAAI,CAAC,CAAC;IAElD,eAAe,CAAC,GAAG,EAAE;QACnB,MAAM,SAAS,GAAG,YAAY,CAAC,OAAO,CAAC;QACvC,IAAI,SAAS,KAAK,IAAI;YAAE,OAAO;QAE/B,OAAO,YAAY,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE;YACzC,IAAI,KAAK,GAAG,CAAC,CAAC;YACd,MAAM,IAAI,GAAG,IAAI,cAAc,CAAC,GAAG,EAAE;gBACnC,uEAAuE;gBACvE,uEAAuE;gBACvE,yDAAyD;gBACzD,oBAAoB,CAAC,KAAK,CAAC,CAAC;gBAC5B,KAAK,GAAG,qBAAqB,CAAC,GAAG,EAAE;oBACjC,qEAAqE;oBACrE,gEAAgE;oBAChE,MAAM,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;oBACpC,iEAAiE;oBACjE,qEAAqE;oBACrE,iDAAiD;oBACjD,IAAI,MAAM,KAAK,CAAC;wBAAE,OAAO;oBACzB,SAAS,CAAC,KAAK,CAAC,SAAS,GAAG,GAAG,kBAAkB,CAAC,MAAM,EAAE,SAAS,CAAC,IAAI,CAAC;gBAC3E,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;YACH,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;YACtB,OAAO,GAAG,EAAE;gBACV,oBAAoB,CAAC,KAAK,CAAC,CAAC;gBAC5B,IAAI,CAAC,UAAU,EAAE,CAAC;YACpB,CAAC,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC;IAE1B,OAAO,YAAY,CAAC;AACtB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-cheminfo",
3
- "version": "0.73.0",
3
+ "version": "0.75.0",
4
4
  "license": "MIT",
5
5
  "description": "React components, hooks and framework-free helpers shared by the cheminfo sites: site chrome, citations, sharing, routing, pedagogy, charts, and the structure, orbital and 3D molecule viewers",
6
6
  "author": "Luc Patiny",
@@ -82,6 +82,7 @@
82
82
  "ir-spectrum": "^4.2.0",
83
83
  "jcampconverter": "^12.5.11",
84
84
  "leven": "^4.1.0",
85
+ "mf-finder": "^3.4.25",
85
86
  "mf-parser": "^3.9.3",
86
87
  "ml-peak-shape-generator": "^5.6.0",
87
88
  "ml-spectra-processing": "^14.35.1",
@@ -112,7 +113,7 @@
112
113
  "@zakodium/tsconfig": "^1.0.5",
113
114
  "benchmark": "^2.1.4",
114
115
  "chemical-elements": "^2.3.2",
115
- "cheminfo-mcp-server": "^0.6.2",
116
+ "cheminfo-mcp-server": "^0.7.0",
116
117
  "eslint": "^9.39.5",
117
118
  "eslint-config-cheminfo-react": "^20.1.0",
118
119
  "eslint-config-cheminfo-typescript": "^22.1.0",
@@ -140,7 +141,7 @@
140
141
  "@blueprintjs/core": "^6.18.0",
141
142
  "@fastify/static": "^10.0.0",
142
143
  "@fastify/type-provider-typebox": "^6.0.0",
143
- "cheminfo-mcp-server": "^0.6.2",
144
+ "cheminfo-mcp-server": "^0.6.2 || ^0.7.0",
144
145
  "fastify": "^5.0.0",
145
146
  "ml-pca": "^4.1.1",
146
147
  "ml-signal-processing": "^2.2.2",
@@ -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
+ }