driftlet 0.1.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 (47) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +285 -0
  3. package/llms.txt +299 -0
  4. package/package.json +54 -0
  5. package/src/bernoulli.js +29 -0
  6. package/src/blockTridiagonal.js +492 -0
  7. package/src/constants.js +36 -0
  8. package/src/data.js +127 -0
  9. package/src/describe.js +190 -0
  10. package/src/device.js +954 -0
  11. package/src/equation.js +188 -0
  12. package/src/errors.js +8 -0
  13. package/src/fermi.js +106 -0
  14. package/src/grid.js +111 -0
  15. package/src/index.js +207 -0
  16. package/src/kit.js +76 -0
  17. package/src/live.js +217 -0
  18. package/src/merge.js +11 -0
  19. package/src/plot.js +267 -0
  20. package/src/solution.js +246 -0
  21. package/src/solver.js +2884 -0
  22. package/src/stack.js +172 -0
  23. package/src/statistics.js +593 -0
  24. package/src/traces.js +125 -0
  25. package/src/types.js +221 -0
  26. package/src/worker.js +24 -0
  27. package/types/bernoulli.d.ts +4 -0
  28. package/types/blockTridiagonal.d.ts +92 -0
  29. package/types/constants.d.ts +33 -0
  30. package/types/data.d.ts +73 -0
  31. package/types/describe.d.ts +15 -0
  32. package/types/device.d.ts +20 -0
  33. package/types/equation.d.ts +102 -0
  34. package/types/errors.d.ts +3 -0
  35. package/types/fermi.d.ts +3 -0
  36. package/types/grid.d.ts +28 -0
  37. package/types/index.d.ts +103 -0
  38. package/types/kit.d.ts +61 -0
  39. package/types/live.d.ts +101 -0
  40. package/types/merge.d.ts +1 -0
  41. package/types/plot.d.ts +60 -0
  42. package/types/solution.d.ts +18 -0
  43. package/types/solver.d.ts +342 -0
  44. package/types/stack.d.ts +59 -0
  45. package/types/statistics.d.ts +10 -0
  46. package/types/traces.d.ts +118 -0
  47. package/types/types.d.ts +574 -0
package/src/kit.js ADDED
@@ -0,0 +1,76 @@
1
+ // driftlet/kit: porcelain. Helpers that read and write plain device-definition data, so a
2
+ // device can be set up from familiar quantities. Nothing here changes what a device means: each
3
+ // helper returns an ordinary spec value (a dipole, a zeroCharge, …) that you can inspect.
4
+
5
+ import { DeviceError } from './errors.js';
6
+ import { FARADAY } from './constants.js';
7
+
8
+ export { build, combine, layer, ohmic, bath } from './stack.js';
9
+ export { parseEquation, stoichiometry, half, SHE, level } from './equation.js';
10
+ export { traces, speciesRole, typeset } from './traces.js';
11
+ export { live } from './live.js';
12
+ export { describe, unitWarnings } from './describe.js';
13
+ export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
14
+
15
+ const fail = (message) => {
16
+ throw new DeviceError(message);
17
+ };
18
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
19
+
20
+ /**
21
+ * One material seen from vacuum: a level inside it (the anchor) and how far above that level,
22
+ * in electron energy, the vacuum level just outside lies (the offset, V). So
23
+ * V_vac = V_anchor − offset.
24
+ * @typedef {object} VacuumSide
25
+ * @property {string} material material name in the definition
26
+ * @property {string} anchor a charged species present there (its standard level
27
+ * V°_i = φ + μ°_i/(z_i F): a band edge for e⁻ or h⁺), or 'phi' (the inner potential itself)
28
+ * @property {number} offset V: an electron affinity, ionisation energy, Trasatti's 4.44 V, …
29
+ */
30
+
31
+ /**
32
+ * A material's vacuum level relative to its own φ, V_vac − φ, in volts.
33
+ * @param {object} def device definition (its `species` and `materials` are read)
34
+ * @param {VacuumSide} side
35
+ * @param {string} [path] for error messages
36
+ */
37
+ export function vacuumLevel(def, side, path = 'vacuum') {
38
+ if (!isObject(side)) fail(`${path} must be { material, anchor, offset }`);
39
+ const mat = def?.materials?.[side.material];
40
+ if (!isObject(mat)) fail(`${path}.material: unknown material ${JSON.stringify(side.material)}`);
41
+ if (mat.conductor !== undefined) fail(`${path}.material: '${side.material}' is a conductor, with no φ; use vacuumZeroCharge with its work function`);
42
+ if (!Number.isFinite(side.offset)) fail(`${path}.offset must be a finite number (V)`);
43
+ if (side.anchor === 'phi') return -side.offset;
44
+ const sp = (def.species ?? []).find((s) => s.name === side.anchor);
45
+ if (!sp) fail(`${path}.anchor must be 'phi' or a charged species, got ${JSON.stringify(side.anchor)}`);
46
+ if (!(sp.z !== 0)) fail(`${path}.anchor: '${side.anchor}' is neutral, so it has no level in volts`);
47
+ const entry = mat.species?.[side.anchor];
48
+ if (!isObject(entry)) fail(`${path}.anchor: '${side.anchor}' is absent from '${side.material}'`);
49
+ if (!Number.isFinite(entry.mu0)) fail(`${path}.anchor: '${side.anchor}' in '${side.material}' has no mu0`);
50
+ return entry.mu0 / (sp.z * FARADAY) - side.offset;
51
+ }
52
+
53
+ /**
54
+ * The vacuum-level (Anderson-type) estimate of a face's alignment: the two sides' vacuum levels
55
+ * are taken to coincide. Returns the face's `dipole`, φ_R − φ_L in volts.
56
+ * @param {object} def device definition
57
+ * @param {VacuumSide} left
58
+ * @param {VacuumSide} right
59
+ */
60
+ export function vacuumDipole(def, left, right) {
61
+ return vacuumLevel(def, left, 'vacuumDipole: left') - vacuumLevel(def, right, 'vacuumDipole: right');
62
+ }
63
+
64
+ /**
65
+ * The vacuum-level (Schottky–Mott-type) estimate of a `zeroCharge` where a conductor meets a
66
+ * material: at a conductor region's face, or a contact's capacitive or dipole law. The conductor's
67
+ * vacuum level sits its work function W beyond its Fermi level, so
68
+ * zeroCharge = V_F − φ_edge at zero charge = W + (V_vac − φ)_inside.
69
+ * @param {object} def device definition
70
+ * @param {number} workFunction V
71
+ * @param {VacuumSide} inside the material across the face
72
+ */
73
+ export function vacuumZeroCharge(def, workFunction, inside) {
74
+ if (!Number.isFinite(workFunction)) fail('vacuumZeroCharge: workFunction must be a finite number (V)');
75
+ return workFunction + vacuumLevel(def, inside, 'vacuumZeroCharge: inside');
76
+ }
package/src/live.js ADDED
@@ -0,0 +1,217 @@
1
+ // A live device for sliders, for driftlet/kit. Changes arrive faster than a device can solve
2
+ // them, so they're merged while a solve runs and only the latest is solved next; each solve
3
+ // starts warm from the last; a change that fails from there is ramped to from the last good
4
+ // state, then tried from scratch; and if it still fails, the last good solution stays. Optionally the solving runs in a
5
+ // Web Worker, keeping the page responsive.
6
+
7
+ import { Device } from './index.js';
8
+ import { merge } from './merge.js';
9
+
10
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !ArrayBuffer.isView(v);
11
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
12
+
13
+ // A point on the way from definition a to b (0 ≤ s ≤ 1): every finite number that differs is
14
+ // interpolated, everything else is b's.
15
+ export function between(a, b, s) {
16
+ if (typeof a === 'number' && typeof b === 'number' && Number.isFinite(a) && Number.isFinite(b)) return a + s * (b - a);
17
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) return b.map((v, k) => between(a[k], v, s));
18
+ if (isPlainObject(a) && isPlainObject(b)) {
19
+ const out = {};
20
+ for (const k of Object.keys(b)) out[k] = k in a ? between(a[k], b[k], s) : b[k];
21
+ return out;
22
+ }
23
+ return b;
24
+ }
25
+
26
+ /**
27
+ * What a live device does with each change, on whichever thread solves it: apply, solve warm,
28
+ * ramp from the last good state if that fails, and fall back to the last good solution.
29
+ */
30
+ export class Session {
31
+ /** @param {object} def device definition */
32
+ constructor(def, { maxRamp = 32 } = {}) {
33
+ this.device = new Device(def);
34
+ this.maxRamp = maxRamp;
35
+ this.good = null; // { cp, sol }: the last converged state
36
+ }
37
+
38
+ /**
39
+ * Apply changes in order, then solve once. Each change is applied on its own, so an invalid
40
+ * one is refused (its message in `errors`) without losing the others.
41
+ * @param {object[]} patches each merged into the definition, as by Device.set()
42
+ * @returns {{ solution: object | null, errors: (string | undefined)[], info: { ms: number, ramp: number, failed: boolean, error?: string, warnings?: string[] } }}
43
+ */
44
+ update(patches) {
45
+ const t0 = now();
46
+ const errors = patches.map(() => undefined);
47
+ let ramp = 0;
48
+ const done = (sol, info) => ({ solution: sol, errors, info: { ms: now() - t0, ramp, ...info } });
49
+ patches.forEach((patch, k) => {
50
+ try {
51
+ this.device.set(patch); // a change that fails leaves the device as it was
52
+ } catch (e) {
53
+ errors[k] = e.message;
54
+ }
55
+ });
56
+ if (this.good && errors.every(Boolean)) return done(this.good.sol, { failed: true }); // nothing changed
57
+ const target = this.device.def;
58
+ try {
59
+ let sol = this.device.solve();
60
+ // Ramp from the last good state in more and more steps.
61
+ for (let n = 2; !sol.converged && this.good && n <= this.maxRamp; n *= 2) {
62
+ ramp = n;
63
+ this.device._rollback(this.good.cp);
64
+ const from = this.good.cp.def;
65
+ for (let k = 1; k <= n; k++) {
66
+ this.device.set(between(from, target, k / n));
67
+ sol = this.device.solve();
68
+ if (!sol.converged) break;
69
+ }
70
+ }
71
+ if (!sol.converged) {
72
+ // Last, from scratch (with the solver's own continuations).
73
+ const fresh = new Device(target);
74
+ const cold = fresh.solve();
75
+ if (cold.converged) {
76
+ this.device = fresh;
77
+ sol = cold;
78
+ }
79
+ }
80
+ if (sol.converged) {
81
+ this.good = { cp: this.device._checkpoint(), sol };
82
+ return done(sol, { failed: false });
83
+ }
84
+ if (this.good) this.device._rollback(this.good.cp);
85
+ return done(this.good?.sol ?? sol, { failed: true, warnings: sol.warnings });
86
+ } catch (e) {
87
+ // Something the definition's checks can't see, found while building or solving.
88
+ if (this.good) this.device._rollback(this.good.cp);
89
+ return done(this.good?.sol ?? null, { failed: true, error: e.message });
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * A live device.
96
+ * @typedef {object} Live
97
+ * @property {(patch: object) => Promise<{ solution: object | null, info: object }>} set merge a change
98
+ * (as Device.set()) and solve it, after the solve under way; changes made meanwhile are merged
99
+ * and solved once, and all their promises resolve with that solution
100
+ * @property {Promise<{ solution: object | null, info: object }>} ready the first solve
101
+ * @property {object | null} solution the last good solution
102
+ * @property {() => void} close stop the worker, if any
103
+ */
104
+
105
+ /**
106
+ * A device for live demos: `live.set(patch)` as often as a slider moves, and draw what comes
107
+ * back (or in `onsolution`). Solves run one at a time, each warm from the last, letting the page
108
+ * paint in between; changes made during a solve are merged and solved together.
109
+ *
110
+ * On failure, the solution is the last good one, with `info.failed` (and `info.error` for an
111
+ * invalid change, `info.warnings` for one that didn't converge). `info.ramp` says how many steps
112
+ * a ramp from the last good state took (0: none needed), and `info.ms` the time.
113
+ *
114
+ * With `worker: true`, solving happens in a Web Worker (module workers are needed, as in every
115
+ * current browser). A function returning a Worker-like object can be given instead.
116
+ * @param {object} def device definition
117
+ * @param {{ worker?: boolean | (() => any), onsolution?: (solution: object, info: object) => void, maxRamp?: number }} [opts]
118
+ * @returns {Live}
119
+ */
120
+ export function live(def, { worker = false, onsolution, maxRamp = 32 } = {}) {
121
+ const engine = worker ? workerEngine(def, worker, maxRamp) : localEngine(def, maxRamp);
122
+ let pending = [], busy = false;
123
+ const api = {
124
+ solution: null,
125
+ set(patch) {
126
+ const p = new Promise((resolve) => pending.push({ patch, resolve }));
127
+ if (!busy) pump();
128
+ return p;
129
+ },
130
+ close: () => engine.close(),
131
+ };
132
+ function pump() {
133
+ if (pending.length === 0) return;
134
+ busy = true;
135
+ const batch = pending;
136
+ pending = [];
137
+ engine
138
+ .run(batch.map((b) => b.patch))
139
+ .catch((e) => ({ solution: api.solution, errors: [], info: { ms: 0, ramp: 0, failed: true, error: String(e?.message ?? e) } }))
140
+ .then((res) => {
141
+ if (res.solution && !res.info.failed) api.solution = res.solution;
142
+ batch.forEach((b, k) => {
143
+ const own = res.errors?.[k];
144
+ b.resolve({ solution: res.solution, info: own ? { ...res.info, failed: true, error: own } : res.info });
145
+ });
146
+ busy = false;
147
+ pump();
148
+ if (onsolution && res.solution) {
149
+ try {
150
+ onsolution(res.solution, res.info);
151
+ } catch (e) {
152
+ // The page's own error: reported (as an uncaught error would be, in a browser) without
153
+ // stopping the device.
154
+ if (typeof globalThis.reportError === 'function') globalThis.reportError(e);
155
+ else console.error(e);
156
+ }
157
+ }
158
+ });
159
+ }
160
+ api.ready = api.set({});
161
+ return api;
162
+ }
163
+
164
+ // Solving on this thread, one macrotask per solve so the page can paint and take input.
165
+ function localEngine(def, maxRamp) {
166
+ let session = null, error = null;
167
+ try {
168
+ session = new Session(def, { maxRamp });
169
+ } catch (e) {
170
+ error = e;
171
+ }
172
+ return {
173
+ run: (patches) =>
174
+ new Promise((resolve) =>
175
+ setTimeout(() => resolve(session ? session.update(patches) : { solution: null, errors: [], info: { ms: 0, ramp: 0, failed: true, error: error.message } }), 0),
176
+ ),
177
+ close() {},
178
+ };
179
+ }
180
+
181
+ // Solving in a worker running worker.js; replies come back in order.
182
+ function workerEngine(def, worker, maxRamp) {
183
+ const w = typeof worker === 'function' ? worker() : startWorker();
184
+ const queue = [];
185
+ const failAll = (message) => {
186
+ while (queue.length) queue.shift()({ solution: null, errors: [], info: { ms: 0, ramp: 0, failed: true, error: message } });
187
+ };
188
+ w.onmessage = (e) => queue.shift()?.(e.data);
189
+ w.onerror = (e) => failAll(`the worker failed: ${e?.message ?? e}`);
190
+ w.postMessage({ type: 'init', def, maxRamp });
191
+ return {
192
+ run(patches) {
193
+ return new Promise((resolve) => {
194
+ queue.push(resolve);
195
+ try {
196
+ w.postMessage({ type: 'update', patches });
197
+ } catch (e) {
198
+ // A change that can't be posted (a function, such as custom statistics).
199
+ queue.pop();
200
+ resolve({ solution: null, errors: patches.map(() => `can't be sent to a worker: ${e.message}`), info: { ms: 0, ramp: 0, failed: true } });
201
+ }
202
+ });
203
+ },
204
+ close: () => w.terminate(),
205
+ };
206
+ }
207
+
208
+ function startWorker() {
209
+ const url = new URL('./worker.js', import.meta.url);
210
+ try {
211
+ return new Worker(url, { type: 'module' });
212
+ } catch {
213
+ // Cross-origin (the library loaded from a CDN): a same-origin stub that imports it.
214
+ const blob = new Blob([`import ${JSON.stringify(url.href)};`], { type: 'text/javascript' });
215
+ return new Worker(URL.createObjectURL(blob), { type: 'module' });
216
+ }
217
+ }
package/src/merge.js ADDED
@@ -0,0 +1,11 @@
1
+ // Deep merge of plain objects, as Device.set() applies a patch: plain objects merge key by key,
2
+ // and arrays, typed arrays and every other value are replaced wholesale.
3
+
4
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !ArrayBuffer.isView(v);
5
+
6
+ export function merge(base, patch) {
7
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
8
+ const out = { ...base };
9
+ for (const [k, v] of Object.entries(patch)) out[k] = isPlainObject(v) && isPlainObject(base[k]) ? merge(base[k], v) : v;
10
+ return out;
11
+ }
package/src/plot.js ADDED
@@ -0,0 +1,267 @@
1
+ // driftlet/plot: a level diagram as an SVG string, with no dependencies and no DOM, so it
2
+ // works the same in a page, a Web Worker or Node. Put it in a page with
3
+ // `element.innerHTML = bandDiagram(sol)`. For your own plotting, `traces()` in driftlet/kit gives
4
+ // the same lines as data.
5
+ //
6
+ // The theme (explained in docs/visualization.md): species voltages V_i are thick solid lines and standard levels
7
+ // V°_i thin solid ones, in the species' colour; redox levels are thick dashed lines and their
8
+ // standard levels thin dashed ones; φ is thin, dotted and grey. Electrons are steel blue, cations
9
+ // (holes too) warm colours, anions cool ones, redox levels blue-violets; each colour follows its
10
+ // species. A species drawn with a display offset carries a ⌇ mark. The colours are CSS custom
11
+ // properties (--driftlet-electron, --driftlet-cation-1 …, --driftlet-anion-1 …,
12
+ // --driftlet-redox-1 …, --driftlet-ink, …) with light and dark defaults of no specificity, so any
13
+ // rule on the page restyles them.
14
+
15
+ import { traces } from './traces.js';
16
+
17
+ // Colours by role, light and dark (validated together for colour-vision deficiency; line weight,
18
+ // dash and the legend carry identity too). A role with more members than colours cycles.
19
+ export const THEME = Object.freeze({
20
+ light: { electron: ['#3a74b4'], cation: ['#d1362f', '#c8650f', '#b8417a', '#8a5a12'], anion: ['#11968a', '#0a9bbd', '#2e8b57'], redox: ['#5a4bc6', '#9a3fae', '#3f5fa8'] },
21
+ dark: { electron: ['#5b95d6'], cation: ['#e8584f', '#d0772a', '#d65d95', '#b88a3e'], anion: ['#219c8f', '#2aa5bb', '#3aa874'], redox: ['#8b7ff0', '#a86bc9', '#7a9ae0'] },
22
+ });
23
+ const ROLES = ['electron', 'cation', 'anion', 'redox'];
24
+
25
+ /**
26
+ * The colour of a trace's role and slot (a CSS colour), for drawing other charts in the same
27
+ * colours as the level diagrams.
28
+ * @param {'electron' | 'cation' | 'anion' | 'redox'} role
29
+ * @param {number} slot
30
+ * @param {{ dark?: boolean }} [opts]
31
+ */
32
+ export function themeColor(role, slot, { dark = false } = {}) {
33
+ const list = THEME[dark ? 'dark' : 'light'][role];
34
+ return list[slot % list.length];
35
+ }
36
+
37
+ // Line weight and dash by kind (thick: what carriers feel; thin: standard levels).
38
+ const STROKE = {
39
+ level: { width: 2.8, dash: '' },
40
+ standard: { width: 1.3, dash: '' },
41
+ redox: { width: 2.8, dash: '8 5' },
42
+ 'redox-standard': { width: 1.3, dash: '6 4' },
43
+ phi: { width: 1.3, dash: '1.5 3' },
44
+ };
45
+
46
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
47
+
48
+ /**
49
+ * A label split into plain and subscript runs. Labels mark subscripts TeX-style: `V_{e⁻}`, or
50
+ * `C_ox` for a run of letters, digits and charge signs. Everything else is literal.
51
+ * @param {string} label
52
+ * @returns {{ text: string, sub: boolean }[]}
53
+ */
54
+ export function labelParts(label) {
55
+ const out = [];
56
+ const re = /_(?:\{([^}]*)\}|([\p{L}\p{N}⁺⁻]+))/gu;
57
+ let at = 0;
58
+ for (const m of String(label).matchAll(re)) {
59
+ if (m.index > at) out.push({ text: label.slice(at, m.index), sub: false });
60
+ out.push({ text: m[1] ?? m[2], sub: true });
61
+ at = m.index + m[0].length;
62
+ }
63
+ if (at < label.length) out.push({ text: label.slice(at), sub: false });
64
+ return out;
65
+ }
66
+ // The label as plain text (for titles and accessible names): braces dropped, `_` kept.
67
+ const plain = (label) => labelParts(label).map((p) => (p.sub ? `_${p.text}` : p.text)).join('');
68
+ // The label as SVG text content, subscripts lowered and shrunk (`dy`, which every browser
69
+ // supports, rather than baseline-shift).
70
+ const rich = (label, font) => {
71
+ const dy = Math.round(0.3 * font * 10) / 10;
72
+ let low = false;
73
+ return labelParts(label)
74
+ .map((p) => {
75
+ const shift = p.sub === low ? '' : ` dy="${p.sub ? dy : -dy}"`;
76
+ low = p.sub;
77
+ return p.sub ? `<tspan${shift} font-size="${0.75 * font}">${esc(p.text)}</tspan>` : shift ? `<tspan${shift}>${esc(p.text)}</tspan>` : esc(p.text);
78
+ })
79
+ .join('');
80
+ };
81
+ // Its width in characters, for layout.
82
+ const span = (label) => labelParts(label).reduce((n, p) => n + (p.sub ? 0.75 : 1) * [...p.text].length, 0);
83
+
84
+ function ticks(a, b, n) {
85
+ const step0 = (b - a) / n;
86
+ const mag = 10 ** Math.floor(Math.log10(step0));
87
+ const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= step0) ?? 10 * mag;
88
+ const out = [];
89
+ for (let t = Math.ceil(a / step) * step; t <= b + 1e-9 * step; t += step) out.push(Math.abs(t) < 1e-9 * step ? 0 : t);
90
+ return out;
91
+ }
92
+ const fmt = (v) => String(+v.toPrecision(4));
93
+
94
+ // The range of the lines within [xs, xe], padded.
95
+ function fit(tr, xs, xe) {
96
+ let lo = Infinity, hi = -Infinity;
97
+ for (const s of tr.series) {
98
+ for (let g = 0; g < tr.x.length; g++) {
99
+ if (tr.x[g] < xs || tr.x[g] > xe || !Number.isFinite(s.y[g])) continue;
100
+ lo = Math.min(lo, s.y[g]);
101
+ hi = Math.max(hi, s.y[g]);
102
+ }
103
+ }
104
+ if (!(hi > lo)) return tr.range;
105
+ const pad = 0.06 * (hi - lo);
106
+ return [lo - pad, hi + pad];
107
+ }
108
+
109
+ // Length unit for the x axis, from the device's extent.
110
+ function lengthUnit(L) {
111
+ if (L >= 1e-3) return { scale: 1e3, name: 'mm' };
112
+ if (L >= 1e-6) return { scale: 1e6, name: 'µm' };
113
+ return { scale: 1e9, name: 'nm' };
114
+ }
115
+
116
+ const style = (id) => {
117
+ const vars = (mode) => ROLES.flatMap((role) => THEME[mode][role].map((c, k) => `--driftlet-${role}${role === 'electron' ? '' : `-${k + 1}`}:${c};`)).join('');
118
+ const light = `${vars('light')}--driftlet-ink:#0b0b0b;--driftlet-muted:#52514e;--driftlet-grid:#e4e3df;--driftlet-band:#f1f0ec;--driftlet-face:#a8a7a1`;
119
+ const dark = `${vars('dark')}--driftlet-ink:#ffffff;--driftlet-muted:#c3c2b7;--driftlet-grid:#3a3a37;--driftlet-band:#262624;--driftlet-face:#6b6a64`;
120
+ return (
121
+ `<style>` +
122
+ `:where(#${id}){${light}}` +
123
+ `@media (prefers-color-scheme: dark){:where(:root:not([data-theme="light"]) #${id}){${dark}}}` +
124
+ `:where(:root[data-theme="dark"] #${id}){${dark}}` +
125
+ `</style>`
126
+ );
127
+ };
128
+
129
+ let counter = 0;
130
+ // Ids unique on a page, even with two copies of this module loaded (a CDN one and a local one).
131
+ const instance = Math.random().toString(36).slice(2, 8);
132
+
133
+ /**
134
+ * Line chart of level-diagram traces as an SVG string.
135
+ * @param {ReturnType<typeof traces>} tr from `traces()`
136
+ * @param {{ width?: number, height?: number, ylabel?: string, range?: [number, number], xlim?: [number, number], title?: string, ytick?: (v: number) => string }} [opts]
137
+ * `xlim` (m) zooms into part of the device; the range then fits what's shown. `ytick` writes
138
+ * the y axis's tick labels, say as powers of ten for lines that are log₁₀ of something
139
+ * @returns {string}
140
+ */
141
+ export function levelChart(tr, { width = 640, height = 360, ylabel = 'voltage (V)', range, xlim, title, ytick = fmt } = {}) {
142
+ const id = `driftlet-plot-${instance}-${++counter}`;
143
+ const { x, series, regions } = tr;
144
+ const xs = xlim ? xlim[0] : x[0], xe = xlim ? xlim[1] : x[x.length - 1];
145
+ const [y0, y1] = range ?? (xlim ? fit(tr, xs, xe) : tr.range);
146
+ const unit = lengthUnit(xe - xs);
147
+
148
+ // Legend entries, flowed into rows above the plot.
149
+ const font = 12, charW = 0.6 * font;
150
+ const rows = [[]];
151
+ let rowW = 0;
152
+ for (const s of series) {
153
+ const w = 28 + span(s.label) * charW + 14;
154
+ if (rowW + w > width - 16 && rows[rows.length - 1].length) {
155
+ rows.push([]);
156
+ rowW = 0;
157
+ }
158
+ rows[rows.length - 1].push({ s, x: 8 + rowW });
159
+ rowW += w;
160
+ }
161
+ const legendH = series.length > 1 ? rows.length * 18 + 8 : 0;
162
+ const pad = { l: 56, r: 14, t: legendH + (title ? 22 : 8) + 16, b: 40 };
163
+ const pw = width - pad.l - pad.r, ph = height - pad.t - pad.b;
164
+ const px = (v) => pad.l + ((v - xs) / (xe - xs)) * pw;
165
+ const py = (v) => pad.t + (1 - (v - y0) / (y1 - y0)) * ph;
166
+ const r2 = (v) => Math.round(v * 100) / 100;
167
+
168
+ const out = [];
169
+ out.push(
170
+ `<svg id="${id}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="max-width:100%;height:auto" role="img" ` +
171
+ `aria-label="${esc(`${title ? `${plain(title)}: ` : ''}${series.map((s) => plain(s.label)).join(', ')} against x`)}" ` +
172
+ `font-family="system-ui, sans-serif" font-size="${font}">`,
173
+ );
174
+ out.push(style(id));
175
+ if (title) out.push(`<text x="8" y="16" fill="var(--driftlet-ink)" font-weight="600">${rich(title, font)}</text>`);
176
+
177
+ // Region bands (every other one shaded) with their materials, and the faces between them.
178
+ regions.forEach((r, k) => {
179
+ if (r.x1 <= xs || r.x0 >= xe) return;
180
+ const a = px(Math.max(r.x0, xs)), b = px(Math.min(r.x1, xe));
181
+ if (k % 2 === 1) out.push(`<rect x="${r2(a)}" y="${pad.t}" width="${r2(b - a)}" height="${ph}" fill="var(--driftlet-band)"/>`);
182
+ // A region's own name where it was given one (not the default 'region k'), with its material.
183
+ const label = r.name && !/^region \d+$/.test(r.name) && r.name !== r.material ? `${r.name} (${r.material})` : r.material;
184
+ const named = r.name && !/^region \d+$/.test(r.name) ? r.name : r.material;
185
+ const shown = [label, named, r.material].find((t) => b - a > t.length * charW + 4) ?? null;
186
+ if (shown) out.push(`<text x="${r2((a + b) / 2)}" y="${pad.t - 5}" text-anchor="middle" fill="var(--driftlet-muted)">${esc(shown)}</text>`);
187
+ });
188
+ // Grid and axes.
189
+ for (const t of ticks(y0, y1, 5)) {
190
+ out.push(`<line x1="${pad.l}" x2="${pad.l + pw}" y1="${r2(py(t))}" y2="${r2(py(t))}" stroke="var(--driftlet-grid)"/>`);
191
+ out.push(`<text x="${pad.l - 6}" y="${r2(py(t) + 4)}" text-anchor="end" fill="var(--driftlet-muted)">${esc(ytick(t))}</text>`);
192
+ }
193
+ for (const t of ticks(xs * unit.scale, xe * unit.scale, 6)) {
194
+ out.push(`<text x="${r2(px(t / unit.scale))}" y="${pad.t + ph + 16}" text-anchor="middle" fill="var(--driftlet-muted)">${fmt(t)}</text>`);
195
+ }
196
+ for (const f of tr.faces.filter((f) => f > xs && f < xe)) out.push(`<line x1="${r2(px(f))}" x2="${r2(px(f))}" y1="${pad.t}" y2="${pad.t + ph}" stroke="var(--driftlet-face)" stroke-dasharray="2 3"/>`);
197
+ out.push(`<text x="${r2(pad.l + pw / 2)}" y="${height - 8}" text-anchor="middle" fill="var(--driftlet-ink)">x (${unit.name})</text>`);
198
+ out.push(`<text transform="translate(14 ${r2(pad.t + ph / 2)}) rotate(-90)" text-anchor="middle" fill="var(--driftlet-ink)">${rich(ylabel, font)}</text>`);
199
+
200
+ // Lines, clipped to the plot, broken where undefined.
201
+ out.push(`<clipPath id="${id}-clip"><rect x="${pad.l}" y="${pad.t}" width="${pw}" height="${ph}"/></clipPath>`);
202
+ out.push(`<g clip-path="url(#${id}-clip)" fill="none" stroke-linejoin="round">`);
203
+ const colour = (s) => {
204
+ if (s.role === 'phi' || !s.role) return 'var(--driftlet-muted)';
205
+ if (s.role === 'electron') return 'var(--driftlet-electron)';
206
+ return `var(--driftlet-${s.role}-${(s.slot % THEME.light[s.role].length) + 1})`;
207
+ };
208
+ const stroke = (s) => {
209
+ const st = STROKE[s.kind] ?? STROKE.level;
210
+ return `stroke="${colour(s)}" stroke-width="${st.width}"${st.dash ? ` stroke-dasharray="${st.dash}"` : ''}`;
211
+ };
212
+ // Thin lines first, so the thick ones the carriers feel sit on top.
213
+ const order = [...series].sort((a, b) => (STROKE[a.kind]?.width ?? 2) - (STROKE[b.kind]?.width ?? 2));
214
+ const marks = [];
215
+ for (const s of order) {
216
+ let d = '', pen = false;
217
+ for (let g = 0; g < x.length; g++) {
218
+ const v = s.y[g];
219
+ if (!Number.isFinite(v)) {
220
+ pen = false;
221
+ continue;
222
+ }
223
+ d += `${pen ? 'L' : 'M'}${r2(px(x[g]))} ${r2(py(v))}`;
224
+ pen = true;
225
+ }
226
+ if (d) out.push(`<path d="${d}" ${stroke(s)}><title>${esc(plain(s.label))}</title></path>`);
227
+ // A species drawn with a display offset: a small zigzag across its V_i, near the left.
228
+ if (s.shift && s.kind === 'level') {
229
+ const g = [...x.keys()].find((k) => x[k] >= xs + 0.04 * (xe - xs) && Number.isFinite(s.y[k]) && s.y[k] >= y0 && s.y[k] <= y1);
230
+ if (g !== undefined) marks.push({ x: px(x[g]), y: py(s.y[g]), s });
231
+ }
232
+ }
233
+ for (const m of marks) {
234
+ const zz = `M${r2(m.x - 3)} ${r2(m.y - 9)}l6 4.5l-6 4.5l6 4.5l-6 4.5`;
235
+ out.push(`<path d="${zz}" stroke="var(--driftlet-ink)" stroke-width="1.6" fill="none"><title>${esc(plain(m.s.label))}</title></path>`);
236
+ }
237
+ out.push(`</g>`);
238
+ if (series.some((s) => s.shift)) {
239
+ out.push(`<text x="${pad.l + pw - 6}" y="${pad.t + ph - 6}" text-anchor="end" fill="var(--driftlet-muted)">⌇ = per-species offset</text>`);
240
+ }
241
+ out.push(`<rect x="${pad.l}" y="${pad.t}" width="${pw}" height="${ph}" fill="none" stroke="var(--driftlet-face)"/>`);
242
+
243
+ // Legend (two or more lines): a swatch in each line's colour and dash, its label in ink.
244
+ if (series.length > 1) {
245
+ rows.forEach((row, k) => {
246
+ const yy = (title ? 22 : 0) + 14 + k * 18;
247
+ for (const { s, x: xx } of row) {
248
+ out.push(`<line x1="${xx}" x2="${xx + 22}" y1="${yy - 4}" y2="${yy - 4}" ${stroke(s)}/>`);
249
+ out.push(`<text x="${xx + 28}" y="${yy}" fill="var(--driftlet-ink)">${rich(s.label, font)}</text>`);
250
+ }
251
+ });
252
+ }
253
+ out.push(`</svg>`);
254
+ return out.join('');
255
+ }
256
+
257
+ /**
258
+ * A solution's level diagram as an SVG string: each charged species' voltage (thick) and
259
+ * standard level (thin), regions as bands. Options are those of `traces()` and `levelChart()`.
260
+ * @param {import('./types.js').Solution} sol
261
+ * @param {Parameters<typeof traces>[1] & Parameters<typeof levelChart>[1]} [opts]
262
+ * @returns {string}
263
+ */
264
+ export function bandDiagram(sol, opts = {}) {
265
+ const { width, height, ylabel, range, xlim, title, ytick, ...pick } = opts;
266
+ return levelChart(traces(sol, pick), { width, height, ylabel, range, xlim, title, ytick });
267
+ }