@cfasim-ui/docs 0.4.8 → 0.4.10

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.
@@ -355,6 +355,53 @@ floor, and `yMin <= 0` is ignored.
355
355
  </template>
356
356
  </ComponentDemo>
357
357
 
358
+ ### Confidence band
359
+
360
+ Use the `areas` prop to fill a band between two y-series — useful for
361
+ confidence intervals or min/max envelopes around a mean line. Each `Area`
362
+ takes parallel `upper` and `lower` arrays (and an optional `x` array, just
363
+ like `Series`).
364
+
365
+ <ComponentDemo>
366
+ <LineChart
367
+ :data="[0, 4, 8, 15, 22, 30, 28, 20, 12, 5, 2]"
368
+ :areas="[
369
+ {
370
+ upper: [2, 8, 14, 22, 30, 38, 36, 28, 19, 11, 7],
371
+ lower: [0, 1, 3, 9, 15, 22, 21, 13, 6, 1, 0],
372
+ color: '#0057b7',
373
+ opacity: 0.15,
374
+ },
375
+ ]"
376
+ :height="220"
377
+ x-label="Days"
378
+ y-label="Cases"
379
+ tooltip-trigger="hover"
380
+ />
381
+
382
+ <template #code>
383
+
384
+ ```vue
385
+ <LineChart
386
+ :data="mean"
387
+ :areas="[
388
+ {
389
+ upper: ci95Hi,
390
+ lower: ci95Lo,
391
+ color: '#0057b7',
392
+ opacity: 0.15,
393
+ },
394
+ ]"
395
+ :height="220"
396
+ x-label="Days"
397
+ y-label="Cases"
398
+ tooltip-trigger="hover"
399
+ />
400
+ ```
401
+
402
+ </template>
403
+ </ComponentDemo>
404
+
358
405
  ### Area sections
359
406
 
360
407
  Highlight a range of a series line by filling the area between the line and the x-axis. Labels are rendered below the chart and automatically stack when they overlap.
@@ -749,6 +796,18 @@ interface Series {
749
796
  }
750
797
  ```
751
798
 
799
+ ### Area
800
+
801
+ ```ts
802
+ interface Area {
803
+ upper: LineChartData;
804
+ lower: LineChartData;
805
+ x?: LineChartData; // optional parallel x-values
806
+ color?: string;
807
+ opacity?: number;
808
+ }
809
+ ```
810
+
752
811
  ### AreaSection
753
812
 
754
813
  ```ts
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.8",
2
+ "version": "0.4.10",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
@@ -172,6 +172,8 @@
172
172
  "time-series",
173
173
  "series",
174
174
  "axis",
175
+ "area",
176
+ "confidence band",
175
177
  "svg"
176
178
  ]
177
179
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -2,7 +2,7 @@ import {
2
2
  postWithTransfer,
3
3
  postModelOutputsWithTransfer,
4
4
  postErrorWithTransfer,
5
- } from "@cfasim-ui/shared";
5
+ } from "@cfasim-ui/shared/transfer";
6
6
  import type { ColumnDescriptor, ModelOutputsWire } from "@cfasim-ui/shared";
7
7
 
8
8
  interface RunMessage {
@@ -231,12 +231,17 @@ function send(id: number, result: unknown) {
231
231
  }
232
232
  }
233
233
 
234
+ // Silence the unhandled-rejection warning if init fails before any message
235
+ // arrives. Each message's onmessage handler still awaits the promise inside
236
+ // its own try/catch, so the rejection is reported back to the caller.
237
+ pyodideReadyPromise.catch(() => {});
238
+
234
239
  self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
235
- const pyodide = await pyodideReadyPromise;
236
240
  const msg = event.data;
237
241
  const { id } = msg;
238
242
 
239
243
  try {
244
+ const pyodide = await pyodideReadyPromise;
240
245
  if (msg.type === "loadModule") {
241
246
  await ensureModule(pyodide, msg.module);
242
247
  postWithTransfer(self, id, true);
@@ -30,24 +30,22 @@ type OutgoingMessage =
30
30
  | Omit<CallMessage, "id">
31
31
  | Omit<LoadModuleMessage, "id">;
32
32
 
33
- function requestResponse(
34
- worker: Worker,
35
- msg: OutgoingMessage,
36
- ): Promise<{ result?: unknown; error?: string }> {
37
- return new Promise((resolve) => {
38
- const id = getId();
39
- function listener(event: MessageEvent<TransferableResponse>) {
40
- if (event.data?.id !== id) return;
41
- worker.removeEventListener("message", listener);
42
- if (event.data.error) {
43
- resolve({ error: event.data.error });
44
- } else {
45
- resolve({ result: unwrapResponse(event.data) });
46
- }
47
- }
48
- worker.addEventListener("message", listener);
49
- worker.postMessage({ id, ...msg } as WorkerMessage);
50
- });
33
+ export type PyodideResponse = { result?: unknown; error?: string };
34
+
35
+ /**
36
+ * Convert a worker `ErrorEvent` into a human-readable message. Cross-origin
37
+ * worker failures sanitize the event so every field is null/empty (e.g. a
38
+ * dynamic `import("https://cdn…")` that fails at module evaluation): in
39
+ * that case we fall back to a hint about likely causes instead of an
40
+ * empty string, which would otherwise surface as a blank error.
41
+ */
42
+ function describeWorkerError(e: ErrorEvent): string {
43
+ const msg = e.message || e.error?.message || "";
44
+ if (msg) {
45
+ if (e.filename) return `${msg} (${e.filename}:${e.lineno})`;
46
+ return msg;
47
+ }
48
+ return "Worker failed to load (no error details available — most often a cross-origin script load failure or a syntax error in the worker module)";
51
49
  }
52
50
 
53
51
  /**
@@ -61,7 +59,11 @@ const DEFAULT_WORKER = "default";
61
59
 
62
60
  interface WorkerEntry {
63
61
  worker: Worker;
64
- modules: Map<string, Promise<{ result?: unknown; error?: string }>>;
62
+ modules: Map<string, Promise<PyodideResponse>>;
63
+ /** Pending request id → resolver, so a worker-level crash can drain them all. */
64
+ pending: Map<number, (r: PyodideResponse) => void>;
65
+ /** Set once the worker has fired an error event — any further call short-circuits to this error. */
66
+ deadError: string | null;
65
67
  }
66
68
 
67
69
  const workers = new Map<string, WorkerEntry>();
@@ -74,7 +76,31 @@ function getWorker(name: string): WorkerEntry {
74
76
  const worker = new Worker(new URL("./pyodide.worker.ts", import.meta.url), {
75
77
  type: "module",
76
78
  });
77
- entry = { worker, modules: new Map() };
79
+ const newEntry: WorkerEntry = {
80
+ worker,
81
+ modules: new Map(),
82
+ pending: new Map(),
83
+ deadError: null,
84
+ };
85
+ function failAll(message: string) {
86
+ if (newEntry.deadError) return;
87
+ newEntry.deadError = message;
88
+ // Drop cached module promises so a future loadModule on a respawned
89
+ // worker (if the caller chooses to retry) re-installs.
90
+ newEntry.modules.clear();
91
+ const pending = Array.from(newEntry.pending.values());
92
+ newEntry.pending.clear();
93
+ for (const resolve of pending) resolve({ error: message });
94
+ }
95
+ worker.addEventListener("error", (e: ErrorEvent) => {
96
+ failAll(describeWorkerError(e));
97
+ });
98
+ worker.addEventListener("messageerror", () => {
99
+ failAll(
100
+ "Worker received a message it could not deserialize (messageerror)",
101
+ );
102
+ });
103
+ entry = newEntry;
78
104
  workers.set(name, entry);
79
105
  for (const mod of sharedModules) {
80
106
  ensureModuleOn(entry, mod);
@@ -83,13 +109,38 @@ function getWorker(name: string): WorkerEntry {
83
109
  return entry;
84
110
  }
85
111
 
112
+ function requestResponse(
113
+ entry: WorkerEntry,
114
+ msg: OutgoingMessage,
115
+ ): Promise<PyodideResponse> {
116
+ if (entry.deadError) {
117
+ return Promise.resolve({ error: entry.deadError });
118
+ }
119
+ return new Promise((resolve) => {
120
+ const id = getId();
121
+ entry.pending.set(id, resolve);
122
+ function listener(event: MessageEvent<TransferableResponse>) {
123
+ if (event.data?.id !== id) return;
124
+ entry.worker.removeEventListener("message", listener);
125
+ entry.pending.delete(id);
126
+ if (event.data.error) {
127
+ resolve({ error: event.data.error });
128
+ } else {
129
+ resolve({ result: unwrapResponse(event.data) });
130
+ }
131
+ }
132
+ entry.worker.addEventListener("message", listener);
133
+ entry.worker.postMessage({ id, ...msg } as WorkerMessage);
134
+ });
135
+ }
136
+
86
137
  function ensureModuleOn(
87
138
  entry: WorkerEntry,
88
139
  moduleName: string,
89
- ): Promise<{ result?: unknown; error?: string }> {
140
+ ): Promise<PyodideResponse> {
90
141
  let p = entry.modules.get(moduleName);
91
142
  if (!p) {
92
- p = requestResponse(entry.worker, {
143
+ p = requestResponse(entry, {
93
144
  type: "loadModule",
94
145
  module: moduleName,
95
146
  });
@@ -106,9 +157,7 @@ function ensureModuleOn(
106
157
  * worker, and on any future worker the moment it spawns. Returns once the
107
158
  * default worker has finished installing.
108
159
  */
109
- export async function loadModule(
110
- moduleName: string,
111
- ): Promise<{ result?: unknown; error?: string }> {
160
+ export async function loadModule(moduleName: string): Promise<PyodideResponse> {
112
161
  sharedModules.add(moduleName);
113
162
  // Always install on the default worker — that's what callers expect when
114
163
  // the function name has no "OnWorker" suffix.
@@ -129,7 +178,7 @@ export async function loadModule(
129
178
  export async function loadModuleOnWorker(
130
179
  moduleName: string,
131
180
  worker: WorkerName,
132
- ): Promise<{ result?: unknown; error?: string }> {
181
+ ): Promise<PyodideResponse> {
133
182
  return ensureModuleOn(getWorker(worker), moduleName);
134
183
  }
135
184
 
@@ -142,8 +191,8 @@ export async function asyncRunPython(
142
191
  script: string,
143
192
  context?: Record<string, unknown>,
144
193
  worker: WorkerName = DEFAULT_WORKER,
145
- ): Promise<{ result?: unknown; error?: string }> {
146
- return requestResponse(getWorker(worker).worker, {
194
+ ): Promise<PyodideResponse> {
195
+ return requestResponse(getWorker(worker), {
147
196
  type: "run",
148
197
  python: script,
149
198
  context,
@@ -160,11 +209,11 @@ export async function callPython(
160
209
  fn: string,
161
210
  kwargs?: Record<string, unknown>,
162
211
  worker: WorkerName = DEFAULT_WORKER,
163
- ): Promise<{ result?: unknown; error?: string }> {
212
+ ): Promise<PyodideResponse> {
164
213
  const entry = getWorker(worker);
165
214
  const installResult = await ensureModuleOn(entry, module);
166
215
  if (installResult.error) return installResult;
167
- return requestResponse(entry.worker, {
216
+ return requestResponse(entry, {
168
217
  type: "call",
169
218
  module,
170
219
  fn,
@@ -2,7 +2,7 @@ import {
2
2
  postWithTransfer,
3
3
  postModelOutputsWithTransfer,
4
4
  postErrorWithTransfer,
5
- } from "@cfasim-ui/shared";
5
+ } from "@cfasim-ui/shared/transfer";
6
6
 
7
7
  interface WorkerMessage {
8
8
  id: number;
@@ -10,23 +10,62 @@ interface WorkerMessage {
10
10
 
11
11
  let lastId = 0;
12
12
 
13
+ /**
14
+ * Convert a worker `ErrorEvent` into a human-readable message. Cross-origin
15
+ * worker failures sanitize the event so every field is null/empty (e.g.
16
+ * the worker's module fails to evaluate); fall back to a hint rather than
17
+ * an empty string.
18
+ */
19
+ function describeWorkerError(e: ErrorEvent): string {
20
+ const msg = e.message || e.error?.message || "";
21
+ if (msg) {
22
+ if (e.filename) return `${msg} (${e.filename}:${e.lineno})`;
23
+ return msg;
24
+ }
25
+ return "Worker failed to load (no error details available — most often a cross-origin script load failure or a syntax error in the worker module)";
26
+ }
27
+
28
+ // Rejects keyed by request id so `cancelWasm` can fail in-flight promises
29
+ // when we terminate the worker mid-run, and so worker-level errors can drain
30
+ // every pending call at once.
31
+ const pendingRejects = new Map<number, (reason: Error) => void>();
32
+
33
+ // Set once the worker fires an `error` / `messageerror` event. Subsequent
34
+ // `runWasm` calls short-circuit with this error so the caller sees a real
35
+ // rejection instead of waiting forever on a worker that will never reply.
36
+ let workerDeadError: Error | null = null;
37
+
13
38
  function newWorker(): Worker {
14
- return new Worker(new URL("./wasm.worker.ts", import.meta.url), {
39
+ const w = new Worker(new URL("./wasm.worker.ts", import.meta.url), {
15
40
  type: "module",
16
41
  });
42
+ function failAll(message: string) {
43
+ workerDeadError = new Error(message);
44
+ const rejects = Array.from(pendingRejects.values());
45
+ pendingRejects.clear();
46
+ for (const reject of rejects) reject(workerDeadError);
47
+ }
48
+ w.addEventListener("error", (e: ErrorEvent) => {
49
+ failAll(describeWorkerError(e));
50
+ });
51
+ w.addEventListener("messageerror", () => {
52
+ failAll(
53
+ "Worker received a message it could not deserialize (messageerror)",
54
+ );
55
+ });
56
+ return w;
17
57
  }
18
58
 
19
59
  let worker = newWorker();
20
60
 
21
- // Rejects keyed by request id so `cancelWasm` can fail in-flight promises
22
- // when we terminate the worker mid-run.
23
- const pendingRejects = new Map<number, (reason: Error) => void>();
24
-
25
61
  export function runWasm(
26
62
  model: string,
27
63
  fn: string,
28
64
  ...args: string[]
29
65
  ): Promise<unknown> {
66
+ if (workerDeadError) {
67
+ return Promise.reject(workerDeadError);
68
+ }
30
69
  return new Promise((resolve, reject) => {
31
70
  const id = ++lastId;
32
71
  // Capture the current worker so a later `cancelWasm` that swaps the
@@ -63,6 +102,7 @@ export function cancelWasm(): void {
63
102
  worker.terminate();
64
103
  const rejects = Array.from(pendingRejects.values());
65
104
  pendingRejects.clear();
105
+ workerDeadError = null;
66
106
  worker = newWorker();
67
107
  for (const reject of rejects) {
68
108
  reject(new Error("cancelled"));