react-cheminfo 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/check-deploy.mjs +3 -1
- package/lib/deploy/core/buildGate.d.ts.map +1 -1
- package/lib/deploy/core/buildGate.js +4 -34
- package/lib/deploy/core/buildGate.js.map +1 -1
- package/lib/deploy/core/checkDeploy.d.ts.map +1 -1
- package/lib/deploy/core/checkDeploy.js +2 -0
- package/lib/deploy/core/checkDeploy.js.map +1 -1
- package/lib/deploy/core/index.d.ts +1 -0
- package/lib/deploy/core/index.d.ts.map +1 -1
- package/lib/deploy/core/index.js +1 -0
- package/lib/deploy/core/index.js.map +1 -1
- package/lib/deploy/core/json.d.ts +27 -0
- package/lib/deploy/core/json.d.ts.map +1 -0
- package/lib/deploy/core/json.js +56 -0
- package/lib/deploy/core/json.js.map +1 -0
- package/lib/deploy/core/reactCopies.d.ts +32 -0
- package/lib/deploy/core/reactCopies.d.ts.map +1 -0
- package/lib/deploy/core/reactCopies.js +212 -0
- package/lib/deploy/core/reactCopies.js.map +1 -0
- package/lib/deploy/core/types.d.ts +1 -1
- package/lib/deploy/core/types.d.ts.map +1 -1
- package/lib/deploy/core/types.js.map +1 -1
- package/lib/ecosystem/core/sites.d.ts.map +1 -1
- package/lib/ecosystem/core/sites.js +12 -0
- package/lib/ecosystem/core/sites.js.map +1 -1
- package/lib/ecosystem/core/types.d.ts +1 -1
- package/lib/ecosystem/core/types.d.ts.map +1 -1
- package/lib/ecosystem/ui/glyphs.d.ts.map +1 -1
- package/lib/ecosystem/ui/glyphs.js +4 -0
- package/lib/ecosystem/ui/glyphs.js.map +1 -1
- package/lib/periodic/core/layout.d.ts +6 -0
- package/lib/periodic/core/layout.d.ts.map +1 -1
- package/lib/periodic/core/layout.js.map +1 -1
- package/lib/periodic/ui/ElementCell.d.ts +22 -2
- package/lib/periodic/ui/ElementCell.d.ts.map +1 -1
- package/lib/periodic/ui/ElementCell.js +20 -145
- package/lib/periodic/ui/ElementCell.js.map +1 -1
- package/lib/periodic/ui/PeriodicTable.d.ts +11 -2
- package/lib/periodic/ui/PeriodicTable.d.ts.map +1 -1
- package/lib/periodic/ui/PeriodicTable.js +21 -3
- package/lib/periodic/ui/PeriodicTable.js.map +1 -1
- package/lib/periodic/ui/PeriodicTableChrome.d.ts +2 -2
- package/lib/periodic/ui/PeriodicTableChrome.d.ts.map +1 -1
- package/lib/periodic/ui/PeriodicTableChrome.js +22 -4
- package/lib/periodic/ui/PeriodicTableChrome.js.map +1 -1
- package/lib/periodic/ui/cellStyles.d.ts +79 -0
- package/lib/periodic/ui/cellStyles.d.ts.map +1 -0
- package/lib/periodic/ui/cellStyles.js +90 -0
- package/lib/periodic/ui/cellStyles.js.map +1 -0
- package/lib/periodic/ui/cellType.d.ts +106 -0
- package/lib/periodic/ui/cellType.d.ts.map +1 -0
- package/lib/periodic/ui/cellType.js +181 -0
- package/lib/periodic/ui/cellType.js.map +1 -0
- package/lib/periodic/ui/glyphWidths.d.ts +11 -0
- package/lib/periodic/ui/glyphWidths.d.ts.map +1 -0
- package/lib/periodic/ui/glyphWidths.js +69 -0
- package/lib/periodic/ui/glyphWidths.js.map +1 -0
- package/package.json +9 -1
- package/src/deploy/core/buildGate.ts +4 -35
- package/src/deploy/core/checkDeploy.ts +7 -0
- package/src/deploy/core/index.ts +1 -0
- package/src/deploy/core/json.ts +53 -0
- package/src/deploy/core/reactCopies.ts +243 -0
- package/src/deploy/core/types.ts +5 -1
- package/src/ecosystem/core/sites.ts +13 -0
- package/src/ecosystem/core/types.ts +2 -1
- package/src/ecosystem/ui/glyphs.tsx +16 -0
- package/src/periodic/core/layout.ts +6 -0
- package/src/periodic/ui/ElementCell.tsx +69 -160
- package/src/periodic/ui/PeriodicTable.tsx +41 -4
- package/src/periodic/ui/PeriodicTableChrome.tsx +27 -8
- package/src/periodic/ui/cellStyles.ts +100 -0
- package/src/periodic/ui/cellType.ts +223 -0
- package/src/periodic/ui/glyphWidths.ts +68 -0
- package/styles/chrome.css +10 -2
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
readInstallRetries,
|
|
8
8
|
readPublishedImage,
|
|
9
9
|
} from './publishedImage.ts';
|
|
10
|
+
import { readReactCopies } from './reactCopies.ts';
|
|
10
11
|
import type { CheckDeployOptions, DeployFile, DeployProblem } from './types.ts';
|
|
11
12
|
import { DEPLOY_COMPOSE_FILES } from './types.ts';
|
|
12
13
|
|
|
@@ -88,6 +89,12 @@ export function findDeployProblems(
|
|
|
88
89
|
|
|
89
90
|
readBuildGate(byPath.get('package.json'), dockerfile, problems);
|
|
90
91
|
|
|
92
|
+
readReactCopies(
|
|
93
|
+
byPath.get('package.json'),
|
|
94
|
+
byPath.get('package-lock.json'),
|
|
95
|
+
problems,
|
|
96
|
+
);
|
|
97
|
+
|
|
91
98
|
readPublishedImage(byPath.get(DOCKER_IMAGE_WORKFLOW), problems);
|
|
92
99
|
|
|
93
100
|
const gitignore = byPath.get('.gitignore');
|
package/src/deploy/core/index.ts
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the repository's own JSON files, which a checker must do without ever
|
|
3
|
+
* throwing: the absence of a file, and a file somebody has broken, are both
|
|
4
|
+
* things to report rather than to crash on.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A JSON file the repository may not have, or may have broken.
|
|
9
|
+
* @param text - The file, unparsed.
|
|
10
|
+
* @returns Its object form, or undefined when it is neither.
|
|
11
|
+
*/
|
|
12
|
+
export function parseJsonObject(
|
|
13
|
+
text: string | undefined,
|
|
14
|
+
): Record<string, unknown> | undefined {
|
|
15
|
+
if (text === undefined) return undefined;
|
|
16
|
+
let parsed: unknown;
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(text);
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
|
23
|
+
return parsed as Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A block of a `package.json` as a map of strings, dropping any entry that is
|
|
28
|
+
* not one — the nested form of `overrides` pins something other than the
|
|
29
|
+
* package it is written under.
|
|
30
|
+
* @param value - The block, as parsed.
|
|
31
|
+
* @returns Its string entries.
|
|
32
|
+
*/
|
|
33
|
+
export function stringEntries(value: unknown): Record<string, string> {
|
|
34
|
+
if (typeof value !== 'object' || value === null) return {};
|
|
35
|
+
const found: Record<string, string> = {};
|
|
36
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
37
|
+
if (typeof entry === 'string') found[key] = entry;
|
|
38
|
+
}
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where a report should point, so the reader lands on the line itself.
|
|
44
|
+
* @param text - The file to search, when there is one.
|
|
45
|
+
* @param needle - The literal to find.
|
|
46
|
+
* @returns Its line, counting from 1, or 0 when it is not there.
|
|
47
|
+
*/
|
|
48
|
+
export function lineOf(text: string | undefined, needle: string): number {
|
|
49
|
+
if (text === undefined) return 0;
|
|
50
|
+
const at = text.indexOf(needle);
|
|
51
|
+
if (at === -1) return 0;
|
|
52
|
+
return text.slice(0, at).split('\n').length;
|
|
53
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
|
|
3
|
+
import { lineOf, parseJsonObject, stringEntries } from './json.ts';
|
|
4
|
+
import type { DeployProblem } from './types.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The React major the whole family runs. One number, in one place: a site that
|
|
8
|
+
* resolves anything else is reporting a resolution nobody asked for.
|
|
9
|
+
*/
|
|
10
|
+
export const REACT_MAJOR = 19;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The packages that must resolve to exactly one copy. The two runtime ones
|
|
14
|
+
* because two copies are a blank page; the two type packages because two copies
|
|
15
|
+
* are a type error in a file nobody edited.
|
|
16
|
+
*/
|
|
17
|
+
const SINGLE_COPY = [
|
|
18
|
+
'react',
|
|
19
|
+
'react-dom',
|
|
20
|
+
'@types/react',
|
|
21
|
+
'@types/react-dom',
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
/** One package as the lock file resolved it. */
|
|
25
|
+
interface Resolved {
|
|
26
|
+
/** Where npm put it, as the lock keys it. */
|
|
27
|
+
path: string;
|
|
28
|
+
/** The exact version it resolved to. */
|
|
29
|
+
version: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the root `package.json` says about a package. */
|
|
33
|
+
interface Declared {
|
|
34
|
+
/** The range a direct dependency asks for, if it is one. */
|
|
35
|
+
dependency?: string;
|
|
36
|
+
/** The range the `overrides` block pins, if it pins one. */
|
|
37
|
+
override?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Report every way a repository would resolve React twice, or resolve a React
|
|
42
|
+
* that is not the family's.
|
|
43
|
+
*
|
|
44
|
+
* Two physical copies of `react` make every component from the offending
|
|
45
|
+
* subtree call hooks against a dispatcher the active renderer never populated,
|
|
46
|
+
* and the app dies at first render on `Cannot read properties of null (reading
|
|
47
|
+
* 'useEffect')` — a blank page with the correct title and the correct CSS,
|
|
48
|
+
* which reads like a routing or a build bug and is neither. Two copies of
|
|
49
|
+
* `@types/react` are the same mistake one layer up: `tsc` rejects a component
|
|
50
|
+
* crossing between them, in a file nobody touched.
|
|
51
|
+
*
|
|
52
|
+
* The cause is almost never a direct dependency. It is a *transitive* peer
|
|
53
|
+
* range that stopped before the current major — `@blueprintjs/core` depends on
|
|
54
|
+
* `react-popper@2`, whose peers stop at `^18` — so npm honours the stale range,
|
|
55
|
+
* installs an older React for that subtree and hoists it. Nothing fails at
|
|
56
|
+
* install, nothing fails at build, and the bundle silently grows by a second
|
|
57
|
+
* copy of React. The fix is a pin in the root `overrides`, which is why a lock
|
|
58
|
+
* holding such a peer without one is reported even while the tree happens to be
|
|
59
|
+
* clean today: it is one fresh resolve away from not being.
|
|
60
|
+
* @param packageJson - The repository's root `package.json`, unparsed.
|
|
61
|
+
* @param packageLock - Its `package-lock.json`, unparsed.
|
|
62
|
+
* @param problems - Where each problem found is appended.
|
|
63
|
+
*/
|
|
64
|
+
export function readReactCopies(
|
|
65
|
+
packageJson: string | undefined,
|
|
66
|
+
packageLock: string | undefined,
|
|
67
|
+
problems: DeployProblem[],
|
|
68
|
+
): void {
|
|
69
|
+
const lock = parseJsonObject(packageLock);
|
|
70
|
+
if (lock === undefined) return;
|
|
71
|
+
const packages = lock.packages;
|
|
72
|
+
if (typeof packages !== 'object' || packages === null) return;
|
|
73
|
+
const entries = Object.entries(packages as Record<string, unknown>);
|
|
74
|
+
|
|
75
|
+
const resolved = resolvedCopies(entries);
|
|
76
|
+
// A repository with no React at all is not a React repository.
|
|
77
|
+
if (resolved.get('react')?.length === undefined) return;
|
|
78
|
+
|
|
79
|
+
const declared = declarations(packageJson);
|
|
80
|
+
|
|
81
|
+
for (const name of SINGLE_COPY) {
|
|
82
|
+
const copies = resolved.get(name);
|
|
83
|
+
if (copies === undefined || copies.length === 0) continue;
|
|
84
|
+
if (copies.length > 1) {
|
|
85
|
+
problems.push({
|
|
86
|
+
file: 'package-lock.json',
|
|
87
|
+
line: lineOf(packageLock, `"${copies[1]?.path ?? name}"`),
|
|
88
|
+
kind: 'duplicate-react',
|
|
89
|
+
message: `${name} resolves to ${copies.length} copies: ${copies
|
|
90
|
+
.map((copy) => `${copy.version} at ${copy.path}`)
|
|
91
|
+
.join(', ')}`,
|
|
92
|
+
hint: `pin ${name} in the root overrides, then reinstall from scratch — npm install alone keeps the stale tree on disk`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const copy of copies) {
|
|
96
|
+
const major = semver.major(semver.coerce(copy.version) ?? '0.0.0');
|
|
97
|
+
if (major !== REACT_MAJOR) {
|
|
98
|
+
problems.push({
|
|
99
|
+
file: 'package-lock.json',
|
|
100
|
+
line: lineOf(packageLock, `"${copy.path}"`),
|
|
101
|
+
kind: 'stray-react-major',
|
|
102
|
+
message: `${name} resolves to ${copy.version} at ${copy.path}, and the family runs React ${REACT_MAJOR}`,
|
|
103
|
+
hint: `declare ^${REACT_MAJOR} and pin it in the root overrides`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const stale = stalePeer(entries);
|
|
110
|
+
if (stale !== undefined) {
|
|
111
|
+
for (const name of ['react', 'react-dom'] as const) {
|
|
112
|
+
if (declared.get(name)?.override !== undefined) continue;
|
|
113
|
+
problems.push({
|
|
114
|
+
file: 'package.json',
|
|
115
|
+
line: lineOf(packageJson, '"dependencies"'),
|
|
116
|
+
kind: 'unpinned-react',
|
|
117
|
+
message: `${stale.name} asks for ${stale.key} ${stale.range}, which excludes React ${REACT_MAJOR}, and nothing pins ${name}`,
|
|
118
|
+
hint: `add "overrides": { "react": "^${REACT_MAJOR}…", "react-dom": "^${REACT_MAJOR}…" } to the root package.json`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const name of SINGLE_COPY) {
|
|
124
|
+
const entry = declared.get(name);
|
|
125
|
+
if (entry?.override === undefined || entry.dependency === undefined) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (entry.override !== entry.dependency) {
|
|
129
|
+
problems.push({
|
|
130
|
+
file: 'package.json',
|
|
131
|
+
line: lineOf(packageJson, `"${name}"`),
|
|
132
|
+
kind: 'conflicting-react-pin',
|
|
133
|
+
message: `${name} is pinned to ${entry.override} while it is depended on as ${entry.dependency}, so npm install fails with EOVERRIDE`,
|
|
134
|
+
hint: `write the same range in both: the override and the dependency are one decision`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every real copy of each tracked package, in lock order. A workspace link is
|
|
142
|
+
* not a copy — it is the repository pointing at itself.
|
|
143
|
+
* @param entries - The lock's `packages`, as entries.
|
|
144
|
+
* @returns The copies found, keyed by package name.
|
|
145
|
+
*/
|
|
146
|
+
function resolvedCopies(
|
|
147
|
+
entries: ReadonlyArray<[string, unknown]>,
|
|
148
|
+
): Map<string, Resolved[]> {
|
|
149
|
+
const found = new Map<string, Resolved[]>();
|
|
150
|
+
for (const [path, value] of entries) {
|
|
151
|
+
if (typeof value !== 'object' || value === null) continue;
|
|
152
|
+
const entry = value as { version?: unknown; link?: unknown };
|
|
153
|
+
if (entry.link === true) continue;
|
|
154
|
+
if (typeof entry.version !== 'string') continue;
|
|
155
|
+
for (const name of SINGLE_COPY) {
|
|
156
|
+
if (
|
|
157
|
+
path !== `node_modules/${name}` &&
|
|
158
|
+
!path.endsWith(`/node_modules/${name}`)
|
|
159
|
+
) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const copies = found.get(name) ?? [];
|
|
163
|
+
copies.push({ path, version: entry.version });
|
|
164
|
+
found.set(name, copies);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return found;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The first package in the lock whose React peer stopped before the family's
|
|
172
|
+
* major — the thing that makes npm resolve a second copy.
|
|
173
|
+
* @param entries - The lock's `packages`, as entries.
|
|
174
|
+
* @returns What asks for what, or undefined when every peer admits the major.
|
|
175
|
+
*/
|
|
176
|
+
function stalePeer(
|
|
177
|
+
entries: ReadonlyArray<[string, unknown]>,
|
|
178
|
+
): { name: string; key: string; range: string } | undefined {
|
|
179
|
+
for (const [path, value] of entries) {
|
|
180
|
+
if (typeof value !== 'object' || value === null) continue;
|
|
181
|
+
const entry = value as {
|
|
182
|
+
peerDependencies?: unknown;
|
|
183
|
+
peerDependenciesMeta?: unknown;
|
|
184
|
+
};
|
|
185
|
+
const peers = entry.peerDependencies;
|
|
186
|
+
if (typeof peers !== 'object' || peers === null) continue;
|
|
187
|
+
const meta = (entry.peerDependenciesMeta ?? {}) as Record<
|
|
188
|
+
string,
|
|
189
|
+
{ optional?: unknown } | undefined
|
|
190
|
+
>;
|
|
191
|
+
for (const key of ['react', 'react-dom']) {
|
|
192
|
+
const range = (peers as Record<string, unknown>)[key];
|
|
193
|
+
if (typeof range !== 'string') continue;
|
|
194
|
+
if (meta[key]?.optional === true) continue;
|
|
195
|
+
if (admitsMajor(range)) continue;
|
|
196
|
+
return { name: path.replace(/^.*node_modules\//, ''), key, range };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether a range admits any release of the family's React major. A range that
|
|
204
|
+
* cannot be parsed is given the benefit of the doubt: a checker that accuses on
|
|
205
|
+
* a spelling it does not know teaches people to ignore it.
|
|
206
|
+
* @param range - The peer range, as written.
|
|
207
|
+
* @returns True when some version of the major satisfies it.
|
|
208
|
+
*/
|
|
209
|
+
function admitsMajor(range: string): boolean {
|
|
210
|
+
try {
|
|
211
|
+
return (
|
|
212
|
+
semver.intersects(range, `>=${REACT_MAJOR}.0.0 <${REACT_MAJOR + 1}.0.0`, {
|
|
213
|
+
includePrerelease: true,
|
|
214
|
+
}) ||
|
|
215
|
+
semver.satisfies(`${REACT_MAJOR}.0.0`, range, { includePrerelease: true })
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* What the root `package.json` asks for and what it pins, per tracked package.
|
|
224
|
+
* @param packageJson - The file, unparsed.
|
|
225
|
+
* @returns The declaration of each tracked package that has one.
|
|
226
|
+
*/
|
|
227
|
+
function declarations(packageJson: string | undefined): Map<string, Declared> {
|
|
228
|
+
const found = new Map<string, Declared>();
|
|
229
|
+
const parsed = parseJsonObject(packageJson);
|
|
230
|
+
if (parsed === undefined) return found;
|
|
231
|
+
const dependencies = {
|
|
232
|
+
...stringEntries(parsed.dependencies),
|
|
233
|
+
...stringEntries(parsed.devDependencies),
|
|
234
|
+
};
|
|
235
|
+
const overrides = stringEntries(parsed.overrides);
|
|
236
|
+
for (const name of SINGLE_COPY) {
|
|
237
|
+
const dependency = dependencies[name];
|
|
238
|
+
const override = overrides[name];
|
|
239
|
+
if (dependency === undefined && override === undefined) continue;
|
|
240
|
+
found.set(name, { dependency, override });
|
|
241
|
+
}
|
|
242
|
+
return found;
|
|
243
|
+
}
|
package/src/deploy/core/types.ts
CHANGED
|
@@ -16,7 +16,11 @@ export type DeployProblemKind =
|
|
|
16
16
|
| 'ungated-build'
|
|
17
17
|
| 'missing-build-only'
|
|
18
18
|
| 'test-runs-e2e'
|
|
19
|
-
| 'gated-image-build'
|
|
19
|
+
| 'gated-image-build'
|
|
20
|
+
| 'duplicate-react'
|
|
21
|
+
| 'stray-react-major'
|
|
22
|
+
| 'unpinned-react'
|
|
23
|
+
| 'conflicting-react-pin';
|
|
20
24
|
|
|
21
25
|
/** One way a repository would fail, or silently mis-deploy, on the server. */
|
|
22
26
|
export interface DeployProblem {
|
|
@@ -311,6 +311,19 @@ export const ECOSYSTEM_SITES: readonly EcosystemSite[] = [
|
|
|
311
311
|
brandAlt: '#0e7490',
|
|
312
312
|
mark: { plate: '#a3620a', accent: '#22d3ee' },
|
|
313
313
|
},
|
|
314
|
+
{
|
|
315
|
+
id: 'powerpoint',
|
|
316
|
+
name: { lead: 'powerpoint', alt: 'cheminfo', dot: true },
|
|
317
|
+
host: 'powerpoint.cheminfo.org',
|
|
318
|
+
tagline:
|
|
319
|
+
'Put a live tool on a slide, from the link its Share button gives.',
|
|
320
|
+
group: 'computing',
|
|
321
|
+
brand: '#c8460d',
|
|
322
|
+
// The blue sits on the white slide of the mark, where it reads at 6.7:1,
|
|
323
|
+
// and is dark enough to set the second half of the wordmark in as well.
|
|
324
|
+
brandAlt: '#1d4ed8',
|
|
325
|
+
mark: { plate: '#c8460d', accent: '#1d4ed8' },
|
|
326
|
+
},
|
|
314
327
|
];
|
|
315
328
|
|
|
316
329
|
/** How a link to a site is written, beyond the site itself. */
|
|
@@ -470,4 +470,20 @@ export const GLYPHS: Record<SiteId, (alt: string) => ReactNode> = {
|
|
|
470
470
|
<circle cx="22.5" cy="22.5" r="4.3" fill={alt} />
|
|
471
471
|
</>
|
|
472
472
|
),
|
|
473
|
+
// A slide on its stand, with a ring standing on it: the slide is the
|
|
474
|
+
// presentation and the ring is the live tool put on it, so the ring carries
|
|
475
|
+
// the answering colour.
|
|
476
|
+
powerpoint: (alt) => (
|
|
477
|
+
<>
|
|
478
|
+
<rect x="4.5" y="6.5" width="23" height="14.5" rx="1.6" fill="#ffffff" />
|
|
479
|
+
<path
|
|
480
|
+
d="M16 21v5M11.5 26.5h9"
|
|
481
|
+
fill="none"
|
|
482
|
+
stroke="#ffffff"
|
|
483
|
+
strokeWidth="2.2"
|
|
484
|
+
strokeLinecap="round"
|
|
485
|
+
/>
|
|
486
|
+
<path d="M16 8.45 20.59 11.1V16.4L16 19.05 11.41 16.4V11.1Z" fill={alt} />
|
|
487
|
+
</>
|
|
488
|
+
),
|
|
473
489
|
};
|
|
@@ -20,6 +20,12 @@ export interface ElementRange {
|
|
|
20
20
|
kind: 'group' | 'period';
|
|
21
21
|
/** Its number: a group from 1 to 18, a period from 1 to 7. */
|
|
22
22
|
value: number;
|
|
23
|
+
/**
|
|
24
|
+
* Whether the click asked for this run on top of what was already chosen —
|
|
25
|
+
* Cmd on a Mac, Ctrl elsewhere — rather than in place of it. A reader
|
|
26
|
+
* comparing two periods holds it down for the second one.
|
|
27
|
+
*/
|
|
28
|
+
additive: boolean;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
/** A cell of the grid, one-based as CSS grid lines are. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One cell of the periodic table: the atomic number, the symbol,
|
|
3
|
-
*
|
|
2
|
+
* One cell of the periodic table: the atomic number, the symbol, whatever the
|
|
3
|
+
* tool asked to be written under it, and — when the tool asks for them — the
|
|
4
|
+
* electrons of each shell down the right-hand edge.
|
|
4
5
|
*
|
|
5
6
|
* A cell is a real `<button>`, so Tab reaches it, Enter and Space activate it,
|
|
6
7
|
* and a screen reader reads the element's name rather than its symbol.
|
|
@@ -10,13 +11,33 @@
|
|
|
10
11
|
* screen — the table a class reads is the same drawing, larger.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
import type {
|
|
14
|
+
import type { ReactElement } from 'react';
|
|
14
15
|
import { useState } from 'react';
|
|
15
16
|
|
|
16
17
|
import type { Swatch } from '../../color/core/interpolate.ts';
|
|
17
18
|
import { useChromeT } from '../../i18n/ui/useT.ts';
|
|
18
19
|
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
SHELLS_AIR,
|
|
22
|
+
SHELLS_COLUMN,
|
|
23
|
+
cellStyle,
|
|
24
|
+
detailStyle,
|
|
25
|
+
numberStyle,
|
|
26
|
+
shellsStyle,
|
|
27
|
+
symbolStyle,
|
|
28
|
+
} from './cellStyles.ts';
|
|
29
|
+
import type { DetailType } from './cellType.ts';
|
|
30
|
+
import {
|
|
31
|
+
SHELLS_RESERVED,
|
|
32
|
+
SYMBOL_CAP,
|
|
33
|
+
condensedScale,
|
|
34
|
+
detailType,
|
|
35
|
+
scaleX,
|
|
36
|
+
shellFontSize,
|
|
37
|
+
symbolFontSize,
|
|
38
|
+
textWidthInEm,
|
|
39
|
+
} from './cellType.ts';
|
|
40
|
+
import { ofWidth } from './unit.ts';
|
|
20
41
|
|
|
21
42
|
/** What {@link ElementCell} needs to draw one element. */
|
|
22
43
|
export interface ElementCellProps {
|
|
@@ -39,6 +60,24 @@ export interface ElementCellProps {
|
|
|
39
60
|
* @default '' — nothing is written
|
|
40
61
|
*/
|
|
41
62
|
detail?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The size that line is written at, from {@link detailType}, so a whole
|
|
65
|
+
* table writes it at one size rather than each cell at its own.
|
|
66
|
+
* @default the largest size this cell's own line fits at
|
|
67
|
+
*/
|
|
68
|
+
detailType?: DetailType;
|
|
69
|
+
/**
|
|
70
|
+
* Electrons in each shell, innermost first, written down the right-hand
|
|
71
|
+
* edge.
|
|
72
|
+
* @default undefined — nothing is written there
|
|
73
|
+
*/
|
|
74
|
+
shells?: readonly number[];
|
|
75
|
+
/**
|
|
76
|
+
* The size they are written at, so the deepest stack in the table fits its
|
|
77
|
+
* cell and every other is written to match.
|
|
78
|
+
* @default the size this cell's own stack fits at
|
|
79
|
+
*/
|
|
80
|
+
shellSize?: string;
|
|
42
81
|
/**
|
|
43
82
|
* Whether the cell is the one the tools are pointed at.
|
|
44
83
|
* @default false
|
|
@@ -78,6 +117,7 @@ export function ElementCell(props: ElementCellProps): ReactElement {
|
|
|
78
117
|
row,
|
|
79
118
|
onSelect,
|
|
80
119
|
detail = '',
|
|
120
|
+
shells,
|
|
81
121
|
isSelected = false,
|
|
82
122
|
isIncluded = true,
|
|
83
123
|
onHover,
|
|
@@ -85,6 +125,11 @@ export function ElementCell(props: ElementCellProps): ReactElement {
|
|
|
85
125
|
const t = useChromeT();
|
|
86
126
|
const [isHovered, setIsHovered] = useState(false);
|
|
87
127
|
|
|
128
|
+
const detailWidth = textWidthInEm(detail);
|
|
129
|
+
const reserved = shells === undefined ? 0 : SHELLS_RESERVED;
|
|
130
|
+
const type = props.detailType ?? detailType([detailWidth], reserved);
|
|
131
|
+
const shellSize = props.shellSize ?? shellFontSize(shells?.length ?? 0);
|
|
132
|
+
|
|
88
133
|
return (
|
|
89
134
|
<button
|
|
90
135
|
type="button"
|
|
@@ -113,6 +158,11 @@ export function ElementCell(props: ElementCellProps): ReactElement {
|
|
|
113
158
|
...cellStyle,
|
|
114
159
|
gridColumn: column,
|
|
115
160
|
gridRow: row,
|
|
161
|
+
// The electrons stand in a column of their own, so the symbol keeps
|
|
162
|
+
// the left of the cell rather than being pushed off its centre.
|
|
163
|
+
gridTemplateColumns:
|
|
164
|
+
shells === undefined ? '1fr' : `1fr ${SHELLS_COLUMN}`,
|
|
165
|
+
columnGap: shells === undefined ? undefined : SHELLS_AIR,
|
|
116
166
|
background: swatch.background,
|
|
117
167
|
color: swatch.foreground,
|
|
118
168
|
opacity: isIncluded ? 1 : 0.28,
|
|
@@ -132,170 +182,29 @@ export function ElementCell(props: ElementCellProps): ReactElement {
|
|
|
132
182
|
<span
|
|
133
183
|
style={{
|
|
134
184
|
...symbolStyle,
|
|
135
|
-
fontSize: symbolFontSize(symbol),
|
|
185
|
+
fontSize: symbolFontSize(symbol, SYMBOL_CAP, reserved),
|
|
136
186
|
}}
|
|
137
187
|
>
|
|
138
188
|
{symbol}
|
|
139
189
|
</span>
|
|
140
190
|
{detail === '' ? null : (
|
|
141
|
-
<span
|
|
191
|
+
<span
|
|
192
|
+
style={{
|
|
193
|
+
...detailStyle,
|
|
194
|
+
fontSize: type.size,
|
|
195
|
+
// Condensed rather than written smaller, so every cell of the
|
|
196
|
+
// table says its name and its value in one type.
|
|
197
|
+
transform: scaleX(condensedScale(detailWidth, type, reserved)),
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
142
200
|
{detail}
|
|
143
201
|
</span>
|
|
144
202
|
)}
|
|
203
|
+
{shells === undefined ? null : (
|
|
204
|
+
<span style={{ ...shellsStyle, fontSize: shellSize }}>
|
|
205
|
+
{shells.join('\n')}
|
|
206
|
+
</span>
|
|
207
|
+
)}
|
|
145
208
|
</button>
|
|
146
209
|
);
|
|
147
210
|
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* The size the symbol is written at.
|
|
151
|
-
*
|
|
152
|
-
* Every symbol is written at the same size, except the widest of them — the
|
|
153
|
-
* ones whose second letter is an `m` — which are written a tenth smaller
|
|
154
|
-
* rather than run into the edges of their cell.
|
|
155
|
-
* @param symbol - The chemical symbol.
|
|
156
|
-
* @returns A CSS length.
|
|
157
|
-
*/
|
|
158
|
-
function symbolFontSize(symbol: string): string {
|
|
159
|
-
return fitted(symbolWidthInEm(symbol), SYMBOL_CAP, '0.5rem');
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* The size the third line takes so the whole of it is written.
|
|
164
|
-
*
|
|
165
|
-
* A value cut short by an ellipsis is a wrong value — `0.0899` read as `0.0…`
|
|
166
|
-
* is worse than not writing it at all — so a long string is written smaller
|
|
167
|
-
* rather than trimmed.
|
|
168
|
-
* @param detail - What the third line says.
|
|
169
|
-
* @returns A CSS length.
|
|
170
|
-
*/
|
|
171
|
-
function detailFontSize(detail: string): string {
|
|
172
|
-
return fitted(detailWidthInEm(detail), DETAIL_CAP, '0.25rem');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* The largest size a string of the given width fits its cell at.
|
|
177
|
-
*
|
|
178
|
-
* The room a cell leaves is a share of the table's width less its border and
|
|
179
|
-
* its padding, which are the same few pixels at every size — hence the two
|
|
180
|
-
* units in one expression.
|
|
181
|
-
* @param widthInEm - How wide the string is, in ems of its own size.
|
|
182
|
-
* @param cap - The size it is written at when it has room to spare.
|
|
183
|
-
* @param floor - The size under which it is not shrunk any further.
|
|
184
|
-
* @returns A CSS length.
|
|
185
|
-
*/
|
|
186
|
-
function fitted(widthInEm: number, cap: string, floor: string): string {
|
|
187
|
-
const room = `calc((${UNIT} * ${String(CELL_SHARE)} - ${CELL_CHROME}) / ${widthInEm.toFixed(2)})`;
|
|
188
|
-
return `max(${floor}, min(${cap}, ${room}))`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* How wide a symbol is, in ems of its own size.
|
|
193
|
-
* @param symbol - The chemical symbol.
|
|
194
|
-
* @returns Its width, never zero.
|
|
195
|
-
*/
|
|
196
|
-
function symbolWidthInEm(symbol: string): number {
|
|
197
|
-
let width = 0;
|
|
198
|
-
for (const character of symbol) {
|
|
199
|
-
if (character === 'm' || character === 'w') {
|
|
200
|
-
width += 0.95;
|
|
201
|
-
} else if (character >= 'a' && character <= 'z') {
|
|
202
|
-
width += 0.63;
|
|
203
|
-
} else {
|
|
204
|
-
width += 0.85;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return Math.max(width, 0.85);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* How wide the third line is, in ems of its own size.
|
|
212
|
-
*
|
|
213
|
-
* It is written with tabular figures, where every glyph — the decimal point
|
|
214
|
-
* and the minus of an exponent included — advances the same width, so counting
|
|
215
|
-
* the characters is the measurement.
|
|
216
|
-
* @param detail - What the third line says.
|
|
217
|
-
* @returns Its width, never zero.
|
|
218
|
-
*/
|
|
219
|
-
function detailWidthInEm(detail: string): number {
|
|
220
|
-
return Math.max(detail.length, 1) * TABULAR_EM;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** What one tabular glyph advances, as a share of its own size. */
|
|
224
|
-
const TABULAR_EM = 0.6;
|
|
225
|
-
|
|
226
|
-
/** The size the symbol is written at wherever it fits, as a share of the table. */
|
|
227
|
-
const SYMBOL_CAP = ofWidth(2.4);
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* The tallest the third line is ever written, as a share of the table.
|
|
231
|
-
*
|
|
232
|
-
* Close to two thirds of the symbol: the symbol is how a cell is found, but
|
|
233
|
-
* the value is what the table is being read for, and a value half the size of
|
|
234
|
-
* the symbol is the one thing on the cell a class cannot make out.
|
|
235
|
-
*/
|
|
236
|
-
const DETAIL_CAP = ofWidth(1.6);
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* The band at each end of a cell: the atomic number at the top, whatever the
|
|
240
|
-
* tool writes at the bottom.
|
|
241
|
-
*
|
|
242
|
-
* They are one length, not two, and that is what makes the symbol between them
|
|
243
|
-
* sit at the centre of the cell rather than near it — a bottom band wider than
|
|
244
|
-
* the top one by a third lifted every symbol in the table two pixels.
|
|
245
|
-
*/
|
|
246
|
-
const EDGE_BAND = ofWidth(1.8);
|
|
247
|
-
|
|
248
|
-
/** How wide one column is, in per cent of the table's width. */
|
|
249
|
-
const CELL_SHARE = 4.85;
|
|
250
|
-
|
|
251
|
-
/** What the border and the padding of a cell take off that width. */
|
|
252
|
-
const CELL_CHROME = '4px';
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* A cell is three bands of its own, and every cell has all three.
|
|
256
|
-
*
|
|
257
|
-
* The atomic number is in the top band, against its left edge; the symbol is
|
|
258
|
-
* centred in the middle one; whatever the tool writes is centred in the
|
|
259
|
-
* bottom one. The bands keep their height whether or not anything is written
|
|
260
|
-
* in the last of them, so a number sits at one height across the whole table
|
|
261
|
-
* and a symbol at one other — before this, both rode on a stack the cell
|
|
262
|
-
* centred and moved whenever a neighbour's value was written smaller.
|
|
263
|
-
*/
|
|
264
|
-
const cellStyle = {
|
|
265
|
-
border: '1px solid rgb(255 255 255 / 0.55)',
|
|
266
|
-
borderRadius: 3,
|
|
267
|
-
cursor: 'pointer',
|
|
268
|
-
display: 'grid',
|
|
269
|
-
font: 'inherit',
|
|
270
|
-
gridTemplateRows: `${EDGE_BAND} 1fr ${EDGE_BAND}`,
|
|
271
|
-
justifyItems: 'center',
|
|
272
|
-
minWidth: 0,
|
|
273
|
-
overflow: 'hidden',
|
|
274
|
-
padding: '1px',
|
|
275
|
-
transition: 'opacity 120ms ease, filter 120ms ease',
|
|
276
|
-
} as const satisfies CSSProperties;
|
|
277
|
-
|
|
278
|
-
const numberStyle = {
|
|
279
|
-
alignSelf: 'start',
|
|
280
|
-
fontSize: `max(0.33rem, ${ofWidth(1.1)})`,
|
|
281
|
-
fontVariantNumeric: 'tabular-nums',
|
|
282
|
-
justifySelf: 'start',
|
|
283
|
-
lineHeight: 1,
|
|
284
|
-
opacity: 0.8,
|
|
285
|
-
} as const satisfies CSSProperties;
|
|
286
|
-
|
|
287
|
-
const symbolStyle = {
|
|
288
|
-
alignSelf: 'center',
|
|
289
|
-
fontWeight: 700,
|
|
290
|
-
letterSpacing: '-0.02em',
|
|
291
|
-
lineHeight: 1.02,
|
|
292
|
-
} as const satisfies CSSProperties;
|
|
293
|
-
|
|
294
|
-
const detailStyle = {
|
|
295
|
-
alignSelf: 'center',
|
|
296
|
-
fontVariantNumeric: 'tabular-nums',
|
|
297
|
-
lineHeight: 1,
|
|
298
|
-
maxWidth: '100%',
|
|
299
|
-
overflow: 'hidden',
|
|
300
|
-
whiteSpace: 'nowrap',
|
|
301
|
-
} as const satisfies CSSProperties;
|