react-cheminfo 0.31.0 → 0.32.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 (59) hide show
  1. package/README.md +1 -0
  2. package/lib/core.d.ts +1 -0
  3. package/lib/core.d.ts.map +1 -1
  4. package/lib/core.js +1 -0
  5. package/lib/core.js.map +1 -1
  6. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  7. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  8. package/lib/locales/de.json +3 -1
  9. package/lib/locales/en.json +3 -1
  10. package/lib/locales/es.json +3 -1
  11. package/lib/locales/fr.json +3 -1
  12. package/lib/number/core/index.d.ts +2 -0
  13. package/lib/number/core/index.d.ts.map +1 -0
  14. package/lib/number/core/index.js +2 -0
  15. package/lib/number/core/index.js.map +1 -0
  16. package/lib/number/core/numberText.d.ts +46 -0
  17. package/lib/number/core/numberText.d.ts.map +1 -0
  18. package/lib/number/core/numberText.js +88 -0
  19. package/lib/number/core/numberText.js.map +1 -0
  20. package/lib/number/ui/NumberInput.d.ts +113 -0
  21. package/lib/number/ui/NumberInput.d.ts.map +1 -0
  22. package/lib/number/ui/NumberInput.js +100 -0
  23. package/lib/number/ui/NumberInput.js.map +1 -0
  24. package/lib/number/ui/index.d.ts +3 -0
  25. package/lib/number/ui/index.d.ts.map +1 -0
  26. package/lib/number/ui/index.js +2 -0
  27. package/lib/number/ui/index.js.map +1 -0
  28. package/lib/periodic/ui/ElementCell.d.ts +10 -0
  29. package/lib/periodic/ui/ElementCell.d.ts.map +1 -1
  30. package/lib/periodic/ui/ElementCell.js +124 -14
  31. package/lib/periodic/ui/ElementCell.js.map +1 -1
  32. package/lib/periodic/ui/PeriodicTable.d.ts.map +1 -1
  33. package/lib/periodic/ui/PeriodicTable.js +46 -19
  34. package/lib/periodic/ui/PeriodicTable.js.map +1 -1
  35. package/lib/periodic/ui/PeriodicTableChrome.js +2 -2
  36. package/lib/periodic/ui/PeriodicTableChrome.js.map +1 -1
  37. package/lib/spectra/ui/NumberField.d.ts +2 -5
  38. package/lib/spectra/ui/NumberField.d.ts.map +1 -1
  39. package/lib/spectra/ui/NumberField.js +4 -54
  40. package/lib/spectra/ui/NumberField.js.map +1 -1
  41. package/lib/ui.d.ts +1 -0
  42. package/lib/ui.d.ts.map +1 -1
  43. package/lib/ui.js +1 -0
  44. package/lib/ui.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/core.ts +1 -0
  47. package/src/locales/de.json +3 -1
  48. package/src/locales/en.json +3 -1
  49. package/src/locales/es.json +3 -1
  50. package/src/locales/fr.json +3 -1
  51. package/src/number/core/index.ts +6 -0
  52. package/src/number/core/numberText.ts +91 -0
  53. package/src/number/ui/NumberInput.tsx +300 -0
  54. package/src/number/ui/index.ts +7 -0
  55. package/src/periodic/ui/ElementCell.tsx +153 -14
  56. package/src/periodic/ui/PeriodicTable.tsx +51 -22
  57. package/src/periodic/ui/PeriodicTableChrome.tsx +2 -2
  58. package/src/spectra/ui/NumberField.tsx +11 -58
  59. package/src/ui.ts +1 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The number a box of text reads as, and the text a number is shown with.
3
+ *
4
+ * A box that hands its text straight to `Number` is a box nobody can type a
5
+ * decimal into: `0.` reads as `0`, and a control rendered from that number
6
+ * writes `0` back over the dot before the next keystroke arrives. So the text
7
+ * and the number are two values here, and the text is the one the reader owns.
8
+ */
9
+
10
+ /** Text that reads as a finite number, comma or dot. */
11
+ const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
12
+
13
+ /** Text a reader is partway through: `-`, `0.`, `1e`, `1e-`, or empty. */
14
+ const PARTIAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d*)(?:e[+-]?\d*)?$/i;
15
+
16
+ /** A sign on its own, which is where typing a negative number starts. */
17
+ const SIGN = /^[+-]?$/;
18
+
19
+ /** Decimals `roundTo` can keep, and the most a step is rounded to. */
20
+ const MAXIMUM_DECIMALS = 15;
21
+
22
+ /**
23
+ * The number some text reads as.
24
+ *
25
+ * A decimal comma is read as a decimal point, because a keyboard laid out for
26
+ * French or German puts a comma where the number pad's decimal key is and a
27
+ * reader typing `0,2` means two tenths.
28
+ * @param text - What is in the box.
29
+ * @param integer - Whether only whole numbers are wanted. Defaults to `false`.
30
+ * @returns The number, or undefined while the text does not read as one.
31
+ */
32
+ export function readNumber(text: string, integer = false): number | undefined {
33
+ const trimmed = text.trim().replace(',', '.');
34
+ if (!NUMBER.test(trimmed)) return undefined;
35
+ const parsed = Number(trimmed);
36
+ if (!Number.isFinite(parsed)) return undefined;
37
+ if (integer && !Number.isInteger(parsed)) return undefined;
38
+ return parsed;
39
+ }
40
+
41
+ /**
42
+ * Whether the text can still become a number as more is typed.
43
+ *
44
+ * `0.`, `-` and `1e-` are all on the way to a number and must not be reported
45
+ * as mistakes; `0.2x` is not, and a box may say so while it is being typed.
46
+ * @param text - What is in the box.
47
+ * @returns Whether anything typed so far rules a number out.
48
+ */
49
+ export function isPartialNumber(text: string): boolean {
50
+ const trimmed = text.trim().replace(',', '.');
51
+ return SIGN.test(trimmed) || PARTIAL.test(trimmed);
52
+ }
53
+
54
+ /**
55
+ * The text a number is shown with.
56
+ * @param value - The number, or nothing when the box is empty.
57
+ * @returns Its text, empty when there is no number.
58
+ */
59
+ export function numberText(value: number | undefined): string {
60
+ return value === undefined || !Number.isFinite(value) ? '' : String(value);
61
+ }
62
+
63
+ /**
64
+ * A number moved by one press of an arrow key or a stepper button.
65
+ *
66
+ * The result is rounded to the decimals the two operands carry, so stepping
67
+ * `0.1` up by `0.2` lands on `0.3` rather than on `0.30000000000000004` — a
68
+ * value that is then shown, shared in a link and stored in full.
69
+ * @param value - Where the box is now.
70
+ * @param delta - What the press adds; negative going down.
71
+ * @returns The number the box moves to.
72
+ */
73
+ export function stepNumber(value: number, delta: number): number {
74
+ const decimals = Math.max(decimalsOf(value), decimalsOf(delta));
75
+ const moved = value + delta;
76
+ if (decimals === 0) return moved;
77
+ const factor = 10 ** decimals;
78
+ return Math.round(moved * factor) / factor;
79
+ }
80
+
81
+ /**
82
+ * How many decimals a number is written with.
83
+ * @param value - The number.
84
+ * @returns Its count of decimals, `0` for anything written in exponent form.
85
+ */
86
+ function decimalsOf(value: number): number {
87
+ const text = String(Math.abs(value));
88
+ const dot = text.indexOf('.');
89
+ if (dot === -1 || text.includes('e')) return 0;
90
+ return Math.min(text.length - dot - 1, MAXIMUM_DECIMALS);
91
+ }
@@ -0,0 +1,300 @@
1
+ import {
2
+ Button,
3
+ ButtonGroup,
4
+ Classes,
5
+ ControlGroup,
6
+ InputGroup,
7
+ } from '@blueprintjs/core';
8
+ import type { CSSProperties, KeyboardEvent, ReactElement } from 'react';
9
+ import { useState } from 'react';
10
+
11
+ import { clamp } from '../../format/core/clamp.ts';
12
+ import { useChromeT } from '../../i18n/ui/useT.ts';
13
+ import {
14
+ isPartialNumber,
15
+ numberText,
16
+ readNumber,
17
+ stepNumber,
18
+ } from '../core/numberText.ts';
19
+
20
+ /** What every number box takes, whether or not it can be left empty. */
21
+ export interface NumberInputBaseProps {
22
+ /**
23
+ * Smallest accepted value, applied when the box is left rather than while it
24
+ * is typed in.
25
+ * @default undefined — unbounded below
26
+ */
27
+ min?: number;
28
+ /**
29
+ * Largest accepted value, applied when the box is left.
30
+ * @default undefined — unbounded above
31
+ */
32
+ max?: number;
33
+ /**
34
+ * What one press of an arrow key or a stepper button adds; Shift multiplies
35
+ * it by ten and Alt divides it by ten.
36
+ * @default 1
37
+ */
38
+ step?: number;
39
+ /**
40
+ * Whether only whole numbers are wanted. A typed decimal is rounded when the
41
+ * box is left.
42
+ * @default false
43
+ */
44
+ integer?: boolean;
45
+ /**
46
+ * Whether the up and down buttons are drawn beside the box.
47
+ * @default true
48
+ */
49
+ buttons?: boolean;
50
+ /**
51
+ * Grey text shown while the box is empty.
52
+ * @default undefined — the box carries no placeholder
53
+ */
54
+ placeholder?: string;
55
+ /** @default false */
56
+ disabled?: boolean;
57
+ /**
58
+ * Whether the control takes the whole width it is given.
59
+ * @default false
60
+ */
61
+ fill?: boolean;
62
+ /** @default 'medium' */
63
+ size?: 'small' | 'medium' | 'large';
64
+ /**
65
+ * Accessible name, for a box no `<label>` points at.
66
+ * @default undefined — the box is named by its label
67
+ */
68
+ ariaLabel?: string;
69
+ /**
70
+ * The box's id, for a `<label htmlFor>` elsewhere in the form.
71
+ * @default undefined
72
+ */
73
+ id?: string;
74
+ /**
75
+ * A name a test finds the box by, written as `data-testid`.
76
+ * @default undefined
77
+ */
78
+ testId?: string;
79
+ /**
80
+ * Drawn inside the box at its left — a unit, a prefix such as `10⁻`.
81
+ * @default undefined
82
+ */
83
+ leftElement?: ReactElement;
84
+ /**
85
+ * Drawn inside the box at its right — a unit, a small button.
86
+ * @default undefined
87
+ */
88
+ rightElement?: ReactElement;
89
+ /** @default undefined */
90
+ className?: string;
91
+ /** @default undefined */
92
+ style?: CSSProperties;
93
+ }
94
+
95
+ /** A box that always holds a number. */
96
+ export interface RequiredNumberInputProps extends NumberInputBaseProps {
97
+ /** The number the box shows. */
98
+ value: number;
99
+ /**
100
+ * Whether an emptied box hands up nothing. A required box keeps the number
101
+ * it had and shows it again when the box is left.
102
+ * @default false
103
+ */
104
+ allowEmpty?: false;
105
+ /** Called with the new number, never while the text is half-typed. */
106
+ onChange: (value: number) => void;
107
+ }
108
+
109
+ /** A box that may be left empty, which hands up `undefined`. */
110
+ export interface OptionalNumberInputProps extends NumberInputBaseProps {
111
+ /** The number the box shows, or nothing when it is empty. */
112
+ value: number | undefined;
113
+ allowEmpty: true;
114
+ /** Called with the new number, or with `undefined` when the box is emptied. */
115
+ onChange: (value: number | undefined) => void;
116
+ }
117
+
118
+ /** See {@link NumberInput}. */
119
+ export type NumberInputProps =
120
+ RequiredNumberInputProps | OptionalNumberInputProps;
121
+
122
+ /**
123
+ * A number typed into a box.
124
+ *
125
+ * The box keeps what was typed rather than what parsed. A reader halfway
126
+ * through `0.2` has typed `0.`, which reads as zero, and a box rendered from
127
+ * that number would write `0` back over the dot before the `2` arrives —
128
+ * which is why Blueprint's `NumericInput` cannot be given a number to show and
129
+ * is never used directly on our sites. Nothing is handed up until the text
130
+ * reads as a number, so a calculation never sees a half-typed value, and the
131
+ * bounds are applied when the box is left rather than under the caret.
132
+ * @param props - See {@link NumberInputProps}.
133
+ * @returns The box, with its stepper buttons unless they are turned off.
134
+ */
135
+ export function NumberInput(props: NumberInputProps): ReactElement {
136
+ const {
137
+ value,
138
+ min,
139
+ max,
140
+ step = 1,
141
+ integer = false,
142
+ allowEmpty = false,
143
+ buttons = true,
144
+ placeholder,
145
+ disabled = false,
146
+ fill = false,
147
+ size = 'medium',
148
+ ariaLabel,
149
+ id,
150
+ testId,
151
+ leftElement,
152
+ rightElement,
153
+ className,
154
+ style,
155
+ } = props;
156
+ const t = useChromeT();
157
+ const onChange = props.onChange as (next: number | undefined) => void;
158
+ const [draft, setDraft] = useState(() => ({
159
+ text: numberText(value),
160
+ held: value,
161
+ }));
162
+
163
+ // The number changed elsewhere — a preset, a reset, a link — so the box has
164
+ // to follow. `held` is what this box last handed up, not the prop it was
165
+ // rendered with: comparing against the prop would make the box's own edit
166
+ // look external and rewrite `-0.` as `0` under the caret.
167
+ if (draft.held !== value) setDraft({ text: numberText(value), held: value });
168
+
169
+ function hold(text: string, held: number | undefined) {
170
+ setDraft({ text, held });
171
+ }
172
+
173
+ function commit(next: number) {
174
+ hold(numberText(next), next);
175
+ onChange(next);
176
+ }
177
+
178
+ function type(text: string) {
179
+ const emptied = text.trim() === '';
180
+ const parsed = readNumber(text, integer);
181
+ hold(text, emptied && allowEmpty ? undefined : (parsed ?? value));
182
+ if (emptied) {
183
+ if (allowEmpty) onChange(undefined);
184
+ } else if (parsed !== undefined) {
185
+ onChange(parsed);
186
+ }
187
+ }
188
+
189
+ function leave() {
190
+ if (draft.text.trim() === '') {
191
+ if (!allowEmpty) hold(numberText(value), value);
192
+ return;
193
+ }
194
+ const parsed = readNumber(draft.text);
195
+ if (parsed === undefined) {
196
+ hold(numberText(value), value);
197
+ return;
198
+ }
199
+ const settled = settle(parsed, integer, min, max);
200
+ if (settled !== parsed || !Object.is(settled, value)) commit(settled);
201
+ }
202
+
203
+ function press(event: KeyboardEvent<HTMLInputElement>) {
204
+ const direction =
205
+ event.key === 'ArrowUp' ? 1 : event.key === 'ArrowDown' ? -1 : 0;
206
+ if (direction === 0 || disabled) return;
207
+ event.preventDefault();
208
+ const scale = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
209
+ move(direction * scale);
210
+ }
211
+
212
+ function move(scale: number) {
213
+ const from = readNumber(draft.text) ?? value ?? 0;
214
+ commit(settle(stepNumber(from, step * scale), integer, min, max));
215
+ }
216
+
217
+ const box = (
218
+ <InputGroup
219
+ id={id}
220
+ size={size}
221
+ fill={fill}
222
+ disabled={disabled}
223
+ placeholder={placeholder}
224
+ value={draft.text}
225
+ intent={isPartialNumber(draft.text) ? 'none' : 'danger'}
226
+ inputMode={integer ? 'numeric' : 'decimal'}
227
+ spellCheck={false}
228
+ autoComplete="off"
229
+ autoCorrect="off"
230
+ autoCapitalize="off"
231
+ aria-label={ariaLabel}
232
+ data-testid={testId}
233
+ leftElement={leftElement}
234
+ rightElement={rightElement}
235
+ className={buttons ? undefined : className}
236
+ style={buttons ? undefined : style}
237
+ onValueChange={type}
238
+ onKeyDown={press}
239
+ onBlur={leave}
240
+ />
241
+ );
242
+
243
+ if (!buttons) return box;
244
+
245
+ const shown = readNumber(draft.text) ?? value;
246
+ return (
247
+ <ControlGroup
248
+ fill={fill}
249
+ style={style}
250
+ className={[Classes.NUMERIC_INPUT, className].filter(Boolean).join(' ')}
251
+ >
252
+ {box}
253
+ <ButtonGroup className={Classes.FIXED} vertical>
254
+ <Button
255
+ icon="chevron-up"
256
+ size={size}
257
+ aria-label={t('number.increment')}
258
+ disabled={
259
+ disabled ||
260
+ (max !== undefined && shown !== undefined && shown >= max)
261
+ }
262
+ onClick={() => move(1)}
263
+ />
264
+ <Button
265
+ icon="chevron-down"
266
+ size={size}
267
+ aria-label={t('number.decrement')}
268
+ disabled={
269
+ disabled ||
270
+ (min !== undefined && shown !== undefined && shown <= min)
271
+ }
272
+ onClick={() => move(-1)}
273
+ />
274
+ </ButtonGroup>
275
+ </ControlGroup>
276
+ );
277
+ }
278
+
279
+ /**
280
+ * The number a box settles on once it is left or stepped.
281
+ * @param value - What the text read as.
282
+ * @param integer - Whether only whole numbers are wanted.
283
+ * @param min - Smallest accepted value, when there is one.
284
+ * @param max - Largest accepted value, when there is one.
285
+ * @returns The rounded, bounded number.
286
+ */
287
+ function settle(
288
+ value: number,
289
+ integer: boolean,
290
+ min: number | undefined,
291
+ max: number | undefined,
292
+ ): number {
293
+ const rounded = integer ? Math.round(value) : value;
294
+ return clamp(
295
+ rounded,
296
+ min ?? Number.NEGATIVE_INFINITY,
297
+ max ?? Number.POSITIVE_INFINITY,
298
+ rounded,
299
+ );
300
+ }
@@ -0,0 +1,7 @@
1
+ export type {
2
+ NumberInputBaseProps,
3
+ NumberInputProps,
4
+ OptionalNumberInputProps,
5
+ RequiredNumberInputProps,
6
+ } from './NumberInput.tsx';
7
+ export { NumberInput } from './NumberInput.tsx';
@@ -4,9 +4,14 @@
4
4
  *
5
5
  * A cell is a real `<button>`, so Tab reaches it, Enter and Space activate it,
6
6
  * and a screen reader reads the element's name rather than its symbol.
7
+ *
8
+ * Everything written in it is sized in container-query units, so a cell shows
9
+ * the same proportions at 280px beside a chart and across a lecture-hall
10
+ * screen — the table a class reads is the same drawing, larger.
7
11
  */
8
12
 
9
13
  import type { CSSProperties, ReactElement } from 'react';
14
+ import { useState } from 'react';
10
15
 
11
16
  import type { Swatch } from '../../color/core/interpolate.ts';
12
17
  import { useChromeT } from '../../i18n/ui/useT.ts';
@@ -32,6 +37,12 @@ export interface ElementCellProps {
32
37
  * @default '' — nothing is written
33
38
  */
34
39
  detail?: string;
40
+ /**
41
+ * Whether the table this cell belongs to writes a third line at all. A table
42
+ * that writes none gives the room to the symbol, and its rows are shorter.
43
+ * @default whether this cell has one
44
+ */
45
+ writesDetail?: boolean;
35
46
  /**
36
47
  * Whether the cell is the one the tools are pointed at.
37
48
  * @default false
@@ -71,11 +82,13 @@ export function ElementCell(props: ElementCellProps): ReactElement {
71
82
  row,
72
83
  onSelect,
73
84
  detail = '',
85
+ writesDetail = detail !== '',
74
86
  isSelected = false,
75
87
  isIncluded = true,
76
88
  onHover,
77
89
  } = props;
78
90
  const t = useChromeT();
91
+ const [isHovered, setIsHovered] = useState(false);
79
92
 
80
93
  return (
81
94
  <button
@@ -83,6 +96,7 @@ export function ElementCell(props: ElementCellProps): ReactElement {
83
96
  className={className}
84
97
  data-testid={`element-${symbol}`}
85
98
  data-symbol={symbol}
99
+ title={detail === '' ? name : `${name} · ${detail}`}
86
100
  aria-label={t('periodic.cellLabel', {
87
101
  name,
88
102
  symbol,
@@ -92,8 +106,14 @@ export function ElementCell(props: ElementCellProps): ReactElement {
92
106
  onClick={() => {
93
107
  onSelect(symbol);
94
108
  }}
95
- onPointerEnter={() => onHover?.(symbol)}
96
- onPointerLeave={() => onHover?.(null)}
109
+ onPointerEnter={() => {
110
+ setIsHovered(true);
111
+ onHover?.(symbol);
112
+ }}
113
+ onPointerLeave={() => {
114
+ setIsHovered(false);
115
+ onHover?.(null);
116
+ }}
97
117
  style={{
98
118
  ...cellStyle,
99
119
  gridColumn: column,
@@ -103,17 +123,134 @@ export function ElementCell(props: ElementCellProps): ReactElement {
103
123
  opacity: isIncluded ? 1 : 0.28,
104
124
  // An outline rather than a fill: a table coloured by a property must
105
125
  // keep saying what the value is while a cell is selected.
106
- outline: isSelected ? `2px solid ${swatch.foreground}` : 'none',
107
- outlineOffset: -3,
126
+ outlineStyle: isSelected ? 'solid' : 'none',
127
+ outlineColor: swatch.foreground,
128
+ // Thick enough to be seen from the back of a room, and drawn inside
129
+ // the cell so a neighbour never covers it.
130
+ outlineWidth: 'max(2px, 0.3cqw)',
131
+ outlineOffset: 'max(-3px, -0.4cqw)',
132
+ // The cell the pointer is on, for a class following a demonstration.
133
+ filter: isHovered ? 'brightness(1.08)' : undefined,
108
134
  }}
109
135
  >
110
136
  <span style={numberStyle}>{atomicNumber}</span>
111
- <span style={symbolStyle}>{symbol}</span>
112
- {detail === '' ? null : <span style={detailStyle}>{detail}</span>}
137
+ <span
138
+ style={{
139
+ ...symbolStyle,
140
+ fontSize: symbolFontSize(symbol, writesDetail),
141
+ }}
142
+ >
143
+ {symbol}
144
+ </span>
145
+ {detail === '' ? null : (
146
+ <span style={{ ...detailStyle, fontSize: detailFontSize(detail) }}>
147
+ {detail}
148
+ </span>
149
+ )}
113
150
  </button>
114
151
  );
115
152
  }
116
153
 
154
+ /**
155
+ * The size the symbol is written at.
156
+ *
157
+ * Every symbol is written at the same size, except the widest of them — the
158
+ * ones whose second letter is an `m` — which are written a tenth smaller
159
+ * rather than run into the edges of their cell.
160
+ * @param symbol - The chemical symbol.
161
+ * @param writesDetail - Whether the table writes a third line under it.
162
+ * @returns A CSS length.
163
+ */
164
+ function symbolFontSize(symbol: string, writesDetail: boolean): string {
165
+ const cap = writesDetail ? SYMBOL_CQW : SYMBOL_ALONE_CQW;
166
+ return fitted(symbolWidthInEm(symbol), cap, '0.5rem');
167
+ }
168
+
169
+ /**
170
+ * The size the third line takes so the whole of it is written.
171
+ *
172
+ * A value cut short by an ellipsis is a wrong value — `0.0899` read as `0.0…`
173
+ * is worse than not writing it at all — so a long string is written smaller
174
+ * rather than trimmed.
175
+ * @param detail - What the third line says.
176
+ * @returns A CSS length.
177
+ */
178
+ function detailFontSize(detail: string): string {
179
+ return fitted(detailWidthInEm(detail), DETAIL_CQW, '0.25rem');
180
+ }
181
+
182
+ /**
183
+ * The largest size a string of the given width fits its cell at.
184
+ *
185
+ * The room a cell leaves is a share of the table's width less its border and
186
+ * its padding, which are the same few pixels at every size — hence the two
187
+ * units in one expression.
188
+ * @param widthInEm - How wide the string is, in ems of its own size.
189
+ * @param cap - The size it is written at when it has room to spare.
190
+ * @param floor - The size under which it is not shrunk any further.
191
+ * @returns A CSS length.
192
+ */
193
+ function fitted(widthInEm: number, cap: string, floor: string): string {
194
+ const room = `calc((${CELL_CQW} - ${CELL_CHROME}) / ${widthInEm.toFixed(2)})`;
195
+ return `max(${floor}, min(${cap}, ${room}))`;
196
+ }
197
+
198
+ /**
199
+ * How wide a symbol is, in ems of its own size.
200
+ * @param symbol - The chemical symbol.
201
+ * @returns Its width, never zero.
202
+ */
203
+ function symbolWidthInEm(symbol: string): number {
204
+ let width = 0;
205
+ for (const character of symbol) {
206
+ if (character === 'm' || character === 'w') {
207
+ width += 0.95;
208
+ } else if (character >= 'a' && character <= 'z') {
209
+ width += 0.63;
210
+ } else {
211
+ width += 0.85;
212
+ }
213
+ }
214
+ return Math.max(width, 0.85);
215
+ }
216
+
217
+ /**
218
+ * How wide the third line is, in ems of its own size.
219
+ *
220
+ * It is written with tabular figures, where every glyph — the decimal point
221
+ * and the minus of an exponent included — advances the same width, so counting
222
+ * the characters is the measurement.
223
+ * @param detail - What the third line says.
224
+ * @returns Its width, never zero.
225
+ */
226
+ function detailWidthInEm(detail: string): number {
227
+ return Math.max(detail.length, 1) * TABULAR_EM;
228
+ }
229
+
230
+ /** What one tabular glyph advances, as a share of its own size. */
231
+ const TABULAR_EM = 0.6;
232
+
233
+ /** The size the symbol is written at wherever it fits, as a share of the table. */
234
+ const SYMBOL_CQW = '2.4cqw';
235
+
236
+ /** The same, in a table that writes nothing under the symbol. */
237
+ const SYMBOL_ALONE_CQW = '2.8cqw';
238
+
239
+ /**
240
+ * The tallest the third line is ever written, as a share of the table.
241
+ *
242
+ * Close to three quarters of the symbol: the symbol is how a cell is found,
243
+ * but the value is what the table is being read for, and a value half the size
244
+ * of the symbol is the one thing on the cell a class cannot make out.
245
+ */
246
+ const DETAIL_CQW = '1.75cqw';
247
+
248
+ /** How wide one column is, as a share of the table's width. */
249
+ const CELL_CQW = '4.85cqw';
250
+
251
+ /** What the border and the padding of a cell take off that width. */
252
+ const CELL_CHROME = '4px';
253
+
117
254
  const cellStyle = {
118
255
  alignItems: 'center',
119
256
  border: '1px solid rgb(255 255 255 / 0.55)',
@@ -123,29 +260,31 @@ const cellStyle = {
123
260
  flexDirection: 'column',
124
261
  font: 'inherit',
125
262
  justifyContent: 'center',
126
- lineHeight: 1.05,
127
263
  minWidth: 0,
128
264
  overflow: 'hidden',
129
- padding: 1,
130
- transition: 'opacity 120ms ease',
265
+ padding: '1px',
266
+ transition: 'opacity 120ms ease, filter 120ms ease',
131
267
  } as const satisfies CSSProperties;
132
268
 
133
269
  const numberStyle = {
134
270
  alignSelf: 'flex-start',
135
- fontSize: 'clamp(0.34rem, 1.6cqw, 0.5rem)',
271
+ fontSize: 'max(0.33rem, 1.2cqw)',
272
+ fontVariantNumeric: 'tabular-nums',
273
+ lineHeight: 1,
136
274
  opacity: 0.8,
137
- paddingLeft: 2,
275
+ paddingLeft: '0.15cqw',
138
276
  } as const satisfies CSSProperties;
139
277
 
140
278
  const symbolStyle = {
141
- fontSize: 'clamp(0.55rem, 3.3cqw, 1.05rem)',
142
279
  fontWeight: 700,
280
+ letterSpacing: '-0.02em',
281
+ lineHeight: 1.02,
143
282
  } as const satisfies CSSProperties;
144
283
 
145
284
  const detailStyle = {
146
- fontSize: 'clamp(0.34rem, 1.6cqw, 0.5rem)',
285
+ fontVariantNumeric: 'tabular-nums',
286
+ lineHeight: 1,
147
287
  maxWidth: '100%',
148
288
  overflow: 'hidden',
149
- textOverflow: 'ellipsis',
150
289
  whiteSpace: 'nowrap',
151
290
  } as const satisfies CSSProperties;