react-cheminfo 0.29.0 → 0.30.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 (58) hide show
  1. package/README.md +42 -2
  2. package/lib/chrome/ui/AccountButton.d.ts +76 -0
  3. package/lib/chrome/ui/AccountButton.d.ts.map +1 -0
  4. package/lib/chrome/ui/AccountButton.js +66 -0
  5. package/lib/chrome/ui/AccountButton.js.map +1 -0
  6. package/lib/chrome/ui/index.d.ts +2 -0
  7. package/lib/chrome/ui/index.d.ts.map +1 -1
  8. package/lib/chrome/ui/index.js +1 -0
  9. package/lib/chrome/ui/index.js.map +1 -1
  10. package/lib/clipboard/ui/ClickToCopy.d.ts +6 -0
  11. package/lib/clipboard/ui/ClickToCopy.d.ts.map +1 -1
  12. package/lib/clipboard/ui/ClickToCopy.js +3 -3
  13. package/lib/clipboard/ui/ClickToCopy.js.map +1 -1
  14. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  15. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  16. package/lib/locales/de.json +3 -0
  17. package/lib/locales/en.json +3 -0
  18. package/lib/locales/es.json +3 -0
  19. package/lib/locales/fr.json +3 -0
  20. package/lib/structure/core/conformerBoltzmann.d.ts +56 -0
  21. package/lib/structure/core/conformerBoltzmann.d.ts.map +1 -0
  22. package/lib/structure/core/conformerBoltzmann.js +96 -0
  23. package/lib/structure/core/conformerBoltzmann.js.map +1 -0
  24. package/lib/structure/core/conformerEnergy.d.ts +69 -0
  25. package/lib/structure/core/conformerEnergy.d.ts.map +1 -0
  26. package/lib/structure/core/conformerEnergy.js +47 -0
  27. package/lib/structure/core/conformerEnergy.js.map +1 -0
  28. package/lib/structure/core/index.d.ts +3 -0
  29. package/lib/structure/core/index.d.ts.map +1 -1
  30. package/lib/structure/core/index.js +2 -0
  31. package/lib/structure/core/index.js.map +1 -1
  32. package/lib/structure/ui/ConformerTable.d.ts +73 -0
  33. package/lib/structure/ui/ConformerTable.d.ts.map +1 -0
  34. package/lib/structure/ui/ConformerTable.js +259 -0
  35. package/lib/structure/ui/ConformerTable.js.map +1 -0
  36. package/lib/structure/ui/conformerColumns.d.ts +30 -0
  37. package/lib/structure/ui/conformerColumns.d.ts.map +1 -0
  38. package/lib/structure/ui/conformerColumns.js +37 -0
  39. package/lib/structure/ui/conformerColumns.js.map +1 -0
  40. package/lib/ui.d.ts +4 -0
  41. package/lib/ui.d.ts.map +1 -1
  42. package/lib/ui.js +2 -0
  43. package/lib/ui.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/chrome/ui/AccountButton.tsx +185 -0
  46. package/src/chrome/ui/index.ts +2 -0
  47. package/src/clipboard/ui/ClickToCopy.tsx +9 -1
  48. package/src/locales/de.json +3 -0
  49. package/src/locales/en.json +3 -0
  50. package/src/locales/es.json +3 -0
  51. package/src/locales/fr.json +3 -0
  52. package/src/structure/core/conformerBoltzmann.ts +102 -0
  53. package/src/structure/core/conformerEnergy.ts +97 -0
  54. package/src/structure/core/index.ts +15 -0
  55. package/src/structure/ui/ConformerTable.tsx +429 -0
  56. package/src/structure/ui/conformerColumns.ts +55 -0
  57. package/src/ui.ts +12 -0
  58. package/styles/chrome.css +55 -0
@@ -0,0 +1,185 @@
1
+ import { Menu, MenuDivider, MenuItem, PopoverNext } from '@blueprintjs/core';
2
+ import type { ReactElement } from 'react';
3
+
4
+ import { useChromeT } from '../../i18n/ui/useT.ts';
5
+
6
+ import { NavLink } from './NavLink.tsx';
7
+ import type { NavItem } from './navItem.ts';
8
+ import { isModifiedClick } from './navItem.ts';
9
+
10
+ /** Who the site is answering to. */
11
+ export interface AccountIdentity {
12
+ /** What the person is called on screen. */
13
+ name: string;
14
+ /**
15
+ * What tells the account apart under that name — the address it is held at,
16
+ * the handle it was opened with.
17
+ * @default undefined
18
+ */
19
+ detail?: string;
20
+ /**
21
+ * The letters the mark is drawn with.
22
+ * @default the first letter of each of the first two words of `name`
23
+ */
24
+ initials?: string;
25
+ }
26
+
27
+ export interface AccountButtonProps {
28
+ /** Who is signed in, or `null` when nobody is. */
29
+ identity: AccountIdentity | null;
30
+ /**
31
+ * Whether the site has not heard back yet. Neither state is drawn until it
32
+ * has: a bar that offers to sign in and then turns into a name has told the
33
+ * visitor something false, and it is the state they act on first.
34
+ * @default false
35
+ */
36
+ loading?: boolean;
37
+ /**
38
+ * The address signing in happens at, for a site with a page of its own for
39
+ * it. Writing it keeps the entry a real link, so a middle click opens a tab.
40
+ * @default undefined
41
+ */
42
+ signInHref?: string;
43
+ /**
44
+ * What the site does when the invitation is picked — route to that page, or
45
+ * open a credentials dialog for a site that has no page.
46
+ * @default undefined
47
+ */
48
+ onSignIn?: () => void;
49
+ /**
50
+ * What the site does when Sign out is picked.
51
+ * @default undefined
52
+ */
53
+ onSignOut?: () => void;
54
+ /**
55
+ * What the invitation reads, for a site whose accounts are for one kind of
56
+ * person — "Teacher sign in". Two words: it is written out in the bar rather
57
+ * than folded into a glyph.
58
+ * @default the chrome's own word for it, in the language of the page
59
+ */
60
+ signInLabel?: string;
61
+ /**
62
+ * The account's own pages, listed above Sign out — a profile, a settings
63
+ * page.
64
+ * @default undefined
65
+ */
66
+ items?: readonly NavItem[];
67
+ }
68
+
69
+ /**
70
+ * The one utility of the bar that says who the site is answering to: an
71
+ * invitation when nobody is signed in, and the account's mark when somebody is.
72
+ *
73
+ * The two states are told apart by whether an identity is on screen, never by
74
+ * the direction of an arrow. Blueprint's `log-in` and `log-out` are the same
75
+ * arrow either side of the same door, and at 14 px among four other monochrome
76
+ * glyphs nobody reads which side it is on — a site drawing one reports the
77
+ * visitor as signed in while they are signed out. So the invitation is the
78
+ * words `Sign in`, which no mark can be mistaken for, and being signed in is
79
+ * the person's own initials.
80
+ * @param props - Who is signed in, what signing in and out do, and the pages
81
+ * the account's menu lists.
82
+ * @returns The entry, or nothing while the site is still asking.
83
+ */
84
+ export function AccountButton(props: AccountButtonProps): ReactElement | null {
85
+ const {
86
+ identity,
87
+ loading = false,
88
+ signInHref,
89
+ onSignIn,
90
+ onSignOut,
91
+ signInLabel,
92
+ items = [],
93
+ } = props;
94
+ const t = useChromeT();
95
+
96
+ if (loading) {
97
+ // The width of the mark, so the bar does not jump when the answer lands.
98
+ return <span className="account-button__pending" aria-hidden="true" />;
99
+ }
100
+
101
+ if (identity === null) {
102
+ return (
103
+ <NavLink
104
+ className="account-button account-button--out"
105
+ item={{
106
+ id: 'account',
107
+ label: signInLabel ?? t('account.signIn'),
108
+ href: signInHref,
109
+ onSelect: onSignIn,
110
+ }}
111
+ />
112
+ );
113
+ }
114
+
115
+ const { name, detail, initials = initialsOf(name) } = identity;
116
+
117
+ return (
118
+ <PopoverNext
119
+ placement="bottom-end"
120
+ content={
121
+ <Menu className="nav-menu account-menu">
122
+ <li className="account-menu__identity">
123
+ <span className="account-menu__name">{name}</span>
124
+ {detail === undefined ? null : (
125
+ <span className="account-menu__detail">{detail}</span>
126
+ )}
127
+ </li>
128
+ <MenuDivider />
129
+ {items.map((item) => (
130
+ <MenuItem
131
+ key={item.id}
132
+ icon={item.icon}
133
+ text={item.label}
134
+ href={item.href}
135
+ target={item.external ? '_blank' : undefined}
136
+ onClick={(event) => {
137
+ if (item.onSelect === undefined || isModifiedClick(event)) {
138
+ return;
139
+ }
140
+ event.preventDefault();
141
+ item.onSelect();
142
+ }}
143
+ />
144
+ ))}
145
+ {items.length === 0 ? null : <MenuDivider />}
146
+ <MenuItem
147
+ icon="log-out"
148
+ text={t('account.signOut')}
149
+ onClick={onSignOut}
150
+ />
151
+ </Menu>
152
+ }
153
+ >
154
+ <button
155
+ type="button"
156
+ className="nav-link nav-link--icon account-button account-button--in"
157
+ title={t('account.signedInAs', { name })}
158
+ aria-label={t('account.signedInAs', { name })}
159
+ >
160
+ <span className="account-mark">{initials}</span>
161
+ </button>
162
+ </PopoverNext>
163
+ );
164
+ }
165
+
166
+ /**
167
+ * The letters a name is marked with: the first of each of its first two words,
168
+ * or the first two letters of a name written as one word.
169
+ * @param name - What the person is called.
170
+ * @returns One or two letters, in capitals.
171
+ */
172
+ function initialsOf(name: string): string {
173
+ const [first, second] = name.split(/\s+/).filter((word) => word !== '');
174
+ if (first === undefined) return '?';
175
+ if (second === undefined) return firstWordInitials(first);
176
+ return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase();
177
+ }
178
+
179
+ // A single word is usually a handle or an address, where the two letters say
180
+ // more than one does — but never the part after the `@`, which every account
181
+ // on the same domain would share.
182
+ function firstWordInitials(word: string): string {
183
+ const [local = ''] = word.split('@');
184
+ return (local === '' ? word : local).slice(0, 2).toUpperCase();
185
+ }
@@ -1,3 +1,5 @@
1
+ export type { AccountButtonProps, AccountIdentity } from './AccountButton.tsx';
2
+ export { AccountButton } from './AccountButton.tsx';
1
3
  export type { HeaderToggleOption, HeaderToggleProps } from './HeaderToggle.tsx';
2
4
  export { HeaderToggle } from './HeaderToggle.tsx';
3
5
  export type { NavItem } from './navItem.ts';
@@ -71,6 +71,12 @@ export interface ClickToCopyProps {
71
71
  * @default undefined
72
72
  */
73
73
  style?: CSSProperties;
74
+ /**
75
+ * `data-testid` the element carries, so an end-to-end suite can select the
76
+ * value without depending on the text inside it.
77
+ * @default undefined
78
+ */
79
+ testId?: string;
74
80
  }
75
81
 
76
82
  /** Past this length the value is left out of the title: a molfile is no tooltip. */
@@ -106,13 +112,14 @@ export function ClickToCopy(props: ClickToCopyProps): ReactElement {
106
112
  disabled = false,
107
113
  className,
108
114
  style,
115
+ testId,
109
116
  } = props;
110
117
  const t = useChromeT();
111
118
  const { copied, failed, copy } = useCopyToClipboard();
112
119
 
113
120
  if (disabled) {
114
121
  return (
115
- <Element className={className} style={style}>
122
+ <Element className={className} style={style} data-testid={testId}>
116
123
  {children}
117
124
  </Element>
118
125
  );
@@ -138,6 +145,7 @@ export function ClickToCopy(props: ClickToCopyProps): ReactElement {
138
145
  className,
139
146
  )}
140
147
  style={style}
148
+ data-testid={testId}
141
149
  role={focusable && !CELLS.has(Element) ? 'button' : undefined}
142
150
  tabIndex={focusable ? 0 : undefined}
143
151
  title={title ?? copyTitle(value, label, t)}
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "language.label": "Sprache",
3
3
  "chrome.pages": "Seiten",
4
+ "account.signIn": "Anmelden",
5
+ "account.signOut": "Abmelden",
6
+ "account.signedInAs": "Angemeldet als {name}",
4
7
  "ecosystem.tools": "Werkzeuge",
5
8
  "ecosystem.menuHeading": "Unsere Werkzeuge, alle im Browser",
6
9
  "ecosystem.otherTools": "Unsere weiteren Werkzeuge",
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "language.label": "Language",
3
3
  "chrome.pages": "Pages",
4
+ "account.signIn": "Sign in",
5
+ "account.signOut": "Sign out",
6
+ "account.signedInAs": "Signed in as {name}",
4
7
  "ecosystem.tools": "Tools",
5
8
  "ecosystem.menuHeading": "Our tools, all in the browser",
6
9
  "ecosystem.otherTools": "Our other tools",
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "language.label": "Idioma",
3
3
  "chrome.pages": "Páginas",
4
+ "account.signIn": "Iniciar sesión",
5
+ "account.signOut": "Cerrar sesión",
6
+ "account.signedInAs": "Sesión iniciada como {name}",
4
7
  "ecosystem.tools": "Herramientas",
5
8
  "ecosystem.menuHeading": "Nuestras herramientas, todas en el navegador",
6
9
  "ecosystem.otherTools": "Nuestras otras herramientas",
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "language.label": "Langue",
3
3
  "chrome.pages": "Pages",
4
+ "account.signIn": "Se connecter",
5
+ "account.signOut": "Se déconnecter",
6
+ "account.signedInAs": "Connecté comme {name}",
4
7
  "ecosystem.tools": "Outils",
5
8
  "ecosystem.menuHeading": "Nos outils, tous dans le navigateur",
6
9
  "ecosystem.otherTools": "Nos autres outils",
@@ -0,0 +1,102 @@
1
+ /**
2
+ * How a set of conformers divides itself between its minima at a temperature.
3
+ *
4
+ * Each conformer in the list is one minimum of the surface, so the share of
5
+ * molecules sitting in it is its Boltzmann factor over the sum of them all.
6
+ * Mirror-image conformers are separate entries and therefore counted twice,
7
+ * which is right: they are two distinct microstates, and that is what makes
8
+ * butane two-thirds gauche at room temperature rather than one-half.
9
+ *
10
+ * What this is not: a free energy. The weights come from a single energy per
11
+ * conformer, with no vibrational entropy, no zero-point energy and no solvent,
12
+ * so a share is the shape of the answer rather than the answer. It is also only
13
+ * as good as the energies handed to it — see {@link boltzmannConfidence}, which
14
+ * is what a table showing these shares says out loud.
15
+ */
16
+
17
+ /** Room temperature, in kelvin, and what a share is quoted at unless asked otherwise. */
18
+ export const ROOM_TEMPERATURE = 300;
19
+
20
+ /**
21
+ * The gas constant in kcal/(mol·K), matching MMFF94's own energy unit.
22
+ *
23
+ * At {@link ROOM_TEMPERATURE} this makes RT 0.596 kcal/mol, so a conformer one
24
+ * kcal/mol up is populated about a fifth as much as the lowest.
25
+ */
26
+ export const GAS_CONSTANT = 0.001_987_204_259;
27
+
28
+ /**
29
+ * The share of molecules in each conformer at thermal equilibrium.
30
+ *
31
+ * The energies are taken relative to whichever is lowest, so they may be
32
+ * absolute or already relative. A conformer whose energy is unknown takes no
33
+ * share and is left out of the sum: it cannot be placed on the surface, and
34
+ * spreading the remainder over the others is a smaller lie than inventing a
35
+ * weight for it.
36
+ * @param energies - One energy per conformer in kcal/mol, `null` where unknown.
37
+ * @param temperature - Temperature in kelvin.
38
+ * @default ROOM_TEMPERATURE
39
+ * @returns One share per conformer, from 0 to 1 and summing to 1 over the ones
40
+ * that have an energy; `null` wherever the energy was `null`.
41
+ */
42
+ export function boltzmannShares(
43
+ energies: ReadonlyArray<number | null>,
44
+ temperature: number = ROOM_TEMPERATURE,
45
+ ): Array<number | null> {
46
+ const shares: Array<number | null> = new Array(energies.length).fill(null);
47
+ if (energies.length === 0) return shares;
48
+
49
+ let lowest = Number.POSITIVE_INFINITY;
50
+ for (const energy of energies) {
51
+ if (energy !== null && Number.isFinite(energy) && energy < lowest) {
52
+ lowest = energy;
53
+ }
54
+ }
55
+ if (lowest === Number.POSITIVE_INFINITY) return shares;
56
+
57
+ // Measured from the lowest, so every exponent is at most zero and the sum
58
+ // cannot overflow however far apart the energies are.
59
+ const rt = GAS_CONSTANT * temperature;
60
+ if (!(rt > 0)) return shares;
61
+
62
+ const factors: Array<number | null> = new Array(energies.length).fill(null);
63
+ let total = 0;
64
+ for (let index = 0; index < energies.length; index++) {
65
+ const energy = energies[index] ?? null;
66
+ if (energy === null || !Number.isFinite(energy)) continue;
67
+ const factor = Math.exp(-(energy - lowest) / rt);
68
+ factors[index] = factor;
69
+ total += factor;
70
+ }
71
+ if (total === 0) return shares;
72
+
73
+ for (let index = 0; index < factors.length; index++) {
74
+ const factor = factors[index] ?? null;
75
+ if (factor !== null) shares[index] = factor / total;
76
+ }
77
+ return shares;
78
+ }
79
+
80
+ /**
81
+ * The factor a share is uncertain by, given how well the energies behind it are
82
+ * known.
83
+ *
84
+ * A population is exponential in the energy gap, so an error in a relative
85
+ * energy is an error in a share by `exp(error / RT)` — and RT is 0.596 kcal/mol
86
+ * at {@link ROOM_TEMPERATURE}. A force field that ranks conformers to within
87
+ * about 1.5 kcal/mol therefore places a population to within a factor of
88
+ * roughly twelve, which is why a share read off one is a shape and not a
89
+ * number.
90
+ * @param energyError - The typical error of a relative energy, kcal/mol.
91
+ * @param temperature - Temperature in kelvin.
92
+ * @default ROOM_TEMPERATURE
93
+ * @returns The multiplicative factor, at least 1.
94
+ */
95
+ export function boltzmannConfidence(
96
+ energyError: number,
97
+ temperature: number = ROOM_TEMPERATURE,
98
+ ): number {
99
+ const rt = GAS_CONSTANT * temperature;
100
+ if (!(rt > 0) || !Number.isFinite(energyError) || energyError <= 0) return 1;
101
+ return Math.exp(energyError / rt);
102
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Which energy of a conformer a reader is being shown.
3
+ *
4
+ * A refined conformer carries two of everything — what the force field said and
5
+ * what the better method said afterwards — and a table that mixes them is a
6
+ * ranking of nothing. So every column reads through one of these, and they all
7
+ * take the same ranking.
8
+ */
9
+
10
+ // Type-only, so nothing here pulls the generator — or openchemlib — into
11
+ // `react-cheminfo/core`, which every page loads.
12
+ import type { ConformerRanking } from './conformers.ts';
13
+
14
+ /**
15
+ * What a refinement has to say for an energy column to read it.
16
+ *
17
+ * The structural minimum rather than the whole `ConformerRefinement`, so a site
18
+ * that carries its conformers in a shape of its own — positions instead of a
19
+ * molfile, say — can still be read by the same helpers and drawn by the same
20
+ * table.
21
+ */
22
+ export interface ConformerRowRefinement {
23
+ /** The rank this conformer held before the refinement reordered the set. */
24
+ forceFieldId: number;
25
+ /** Total energy at the relaxed geometry, kcal/mol. */
26
+ energy: number;
27
+ /** Energy above the most stable refined conformer, kcal/mol. */
28
+ relativeEnergy: number;
29
+ }
30
+
31
+ /**
32
+ * The least a conformer must carry to be ranked and drawn.
33
+ *
34
+ * `Conformer` from `react-cheminfo/conformers` satisfies it, and so does any
35
+ * per-site record that keeps the same three numbers.
36
+ */
37
+ export interface ConformerRow {
38
+ /** 1-based rank in the set, most stable first. */
39
+ id: number;
40
+ /** Total force-field energy in kcal/mol, `null` when not computed. */
41
+ energy: number | null;
42
+ /** Energy above the most stable conformer, `null` without an energy. */
43
+ relativeEnergy: number | null;
44
+ /**
45
+ * What a better method said, once the set has been refined.
46
+ * @default null
47
+ */
48
+ refinement?: ConformerRowRefinement | null;
49
+ }
50
+
51
+ /**
52
+ * The energy above the most stable conformer, in the ranking asked for.
53
+ * @param conformer - The conformer to read.
54
+ * @param ranking - Which method's energies to read.
55
+ * @default 'force-field'
56
+ * @returns kcal/mol above the lowest, or `null` when that method produced none.
57
+ */
58
+ export function relativeEnergyOf(
59
+ conformer: ConformerRow,
60
+ ranking: ConformerRanking = 'force-field',
61
+ ): number | null {
62
+ return ranking === 'refined'
63
+ ? (conformer.refinement?.relativeEnergy ?? null)
64
+ : conformer.relativeEnergy;
65
+ }
66
+
67
+ /**
68
+ * The total energy, in the ranking asked for, so the two columns of a row
69
+ * describe one method.
70
+ * @param conformer - The conformer to read.
71
+ * @param ranking - Which method's energies to read.
72
+ * @default 'force-field'
73
+ * @returns kcal/mol, or `null` when that method produced none.
74
+ */
75
+ export function totalEnergyOf(
76
+ conformer: ConformerRow,
77
+ ranking: ConformerRanking = 'force-field',
78
+ ): number | null {
79
+ return ranking === 'refined'
80
+ ? (conformer.refinement?.energy ?? null)
81
+ : conformer.energy;
82
+ }
83
+
84
+ /**
85
+ * The ranking a set should be read in: refined once anything has been refined,
86
+ * the force field's until then.
87
+ * @param conformers - The set to inspect.
88
+ * @returns `'refined'` when at least one conformer carries a refinement.
89
+ */
90
+ export function bestRanking(
91
+ conformers: readonly ConformerRow[],
92
+ ): ConformerRanking {
93
+ for (const conformer of conformers) {
94
+ if (conformer.refinement != null) return 'refined';
95
+ }
96
+ return 'force-field';
97
+ }
@@ -1,5 +1,20 @@
1
1
  export type { AtomLabelPlacement, LabelledMolecule } from './atomLabels.ts';
2
2
  export { applyAtomLabels, customAtomLabel } from './atomLabels.ts';
3
+ export {
4
+ GAS_CONSTANT,
5
+ ROOM_TEMPERATURE,
6
+ boltzmannConfidence,
7
+ boltzmannShares,
8
+ } from './conformerBoltzmann.ts';
9
+ export type {
10
+ ConformerRow,
11
+ ConformerRowRefinement,
12
+ } from './conformerEnergy.ts';
13
+ export {
14
+ bestRanking,
15
+ relativeEnergyOf,
16
+ totalEnergyOf,
17
+ } from './conformerEnergy.ts';
3
18
  export type {
4
19
  EditorGesture,
5
20
  EditorGuideKey,