An easy-to-use platform for EEG experimentation in the classroom
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add SVG and PNG buttons

+236 -18
+135
docs/pyodide-in-electron-vite.md
··· 163 163 164 164 --- 165 165 166 + ## Plot Pipeline 167 + 168 + ### matplotlib Backend in Web Workers 169 + 170 + Use `agg`, not `webagg`. Set it before any Python imports run: 171 + 172 + ```js 173 + await pyodide.runPythonAsync('import os; os.environ["MPLBACKEND"] = "agg"'); 174 + ``` 175 + 176 + WebAgg (`webagg`) fails in web workers because it tries to inject CSS via `js.document` during initialisation — and `js.document` does not exist in worker scope. The error looks like: 177 + 178 + ``` 179 + ImportError: cannot import name 'document' from 'js' 180 + ``` 181 + 182 + `agg` is a non-interactive raster backend that writes to a buffer, which is exactly what we need. 183 + 184 + --- 185 + 186 + ### plotKey Correlation Pattern (Fire-and-Forget Messaging) 187 + 188 + `worker.postMessage()` returns `undefined` — there is no return channel. Redux-Observable plot load epics cannot receive the worker's result directly. 189 + 190 + **Solution:** attach a `plotKey` string to every outgoing message; the worker echoes it back in the response object. `pyodideMessageEpic` routes by `plotKey` to the correct Redux action. 191 + 192 + ```js 193 + // webworker.js — echo plotKey back in every response 194 + const { data, plotKey, ...context } = event.data; 195 + self.postMessage({ results: await pyodide.runPythonAsync(data), plotKey }); 196 + ``` 197 + 198 + ```ts 199 + // pyodideMessageEpic — route by plotKey 200 + switch (plotKey) { 201 + case 'ready': return of(PyodideActions.SetWorkerReady()); 202 + case 'topo': return of(PyodideActions.SetTopoPlot(mimeBundle)); 203 + case 'psd': return of(PyodideActions.SetPSDPlot(mimeBundle)); 204 + case 'erp': return of(PyodideActions.SetERPPlot(mimeBundle)); 205 + default: return of(PyodideActions.ReceiveMessage(e.data)); 206 + } 207 + ``` 208 + 209 + Plot load epics become fire-and-forget — they call `worker.postMessage()` as a side effect and emit nothing: 210 + 211 + ```ts 212 + // loadTopoEpic 213 + action$.pipe( 214 + filter(isActionOf(PyodideActions.LoadTopo)), 215 + tap(() => plotTestPlot(state$.value.pyodide.worker!)), 216 + mergeMap(() => EMPTY) 217 + ); 218 + ``` 219 + 220 + --- 221 + 222 + ### Worker Readiness Gating 223 + 224 + `loadUtils` posts `plotKey: 'ready'` when `utils.py` finishes loading. This drives an `isWorkerReady` flag in Redux state that gates any UI that depends on Python being initialised. 225 + 226 + ```ts 227 + export const loadUtils = async (worker: Worker) => 228 + worker.postMessage({ data: utilsPy, plotKey: 'ready' }); 229 + ``` 230 + 231 + `pyodideMessageEpic` dispatches `PyodideActions.SetWorkerReady()` on receiving `plotKey === 'ready'`. 232 + 233 + --- 234 + 235 + ### SVG Output from matplotlib 236 + 237 + Produce SVG in Python — no base64 encoding needed: 238 + 239 + ```python 240 + import io 241 + import matplotlib.pyplot as plt 242 + 243 + _fig, _ax = plt.subplots() 244 + _ax.plot([1, 2, 3, 4], [1, 4, 2, 3]) 245 + _buf = io.BytesIO() 246 + _fig.savefig(_buf, format="svg", bbox_inches="tight") 247 + plt.close(_fig) 248 + _buf.getvalue().decode() # SVG string is the Python return value 249 + ``` 250 + 251 + The SVG string flows through `pyodide.runPythonAsync()` → worker `postMessage` → Redux state as `{ 'image/svg+xml': string }`. 252 + 253 + --- 254 + 255 + ### Rendering SVG Safely in the Renderer 256 + 257 + Use a data URI on an `<img>` tag — sandboxed, no script execution: 258 + 259 + ```tsx 260 + <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} /> 261 + ``` 262 + 263 + Prefer this over `dangerouslySetInnerHTML` — inline SVG executes `<script>` tags and has full DOM access. 264 + 265 + --- 266 + 267 + ### PNG Export from SVG 268 + 269 + Simple canvas conversion using the SVG's natural pixel dimensions: 270 + 271 + ```ts 272 + function svgToPngArrayBuffer(svg: string): Promise<ArrayBuffer> { 273 + return new Promise((resolve, reject) => { 274 + const blob = new Blob([svg], { type: 'image/svg+xml' }); 275 + const url = URL.createObjectURL(blob); 276 + const img = new Image(); 277 + img.onload = () => { 278 + const canvas = document.createElement('canvas'); 279 + canvas.width = img.naturalWidth; 280 + canvas.height = img.naturalHeight; 281 + const ctx = canvas.getContext('2d')!; 282 + ctx.drawImage(img, 0, 0); 283 + URL.revokeObjectURL(url); 284 + canvas.toBlob((pngBlob) => { 285 + pngBlob!.arrayBuffer().then(resolve).catch(reject); 286 + }, 'image/png'); 287 + }; 288 + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG load failed')); }; 289 + img.src = url; 290 + }); 291 + } 292 + ``` 293 + 294 + The resulting `ArrayBuffer` is passed through the IPC chain (`preload → main`) for file write. Electron's `contextBridge` serialises `ArrayBuffer` correctly without any additional conversion. 295 + 296 + --- 297 + 166 298 ## Summary of File Locations 167 299 168 300 | What | Where | ··· 174 306 | Install script | `internals/scripts/InstallMNE.mjs` | 175 307 | Electron asset server | `src/main/index.ts` → `startPyodideAssetServer()` | 176 308 | Vite middleware | `vite.config.ts` → `serve-pyodide-assets` plugin | 309 + | Plot widget component | `src/renderer/components/PyodidePlotWidget.tsx` | 310 + | Plot epics (Redux-Observable) | `src/renderer/epics/pyodideEpics.ts` | 311 + | Pyodide Redux state | `src/renderer/reducers/pyodideReducer.ts` |
+17 -4
src/main/index.ts
··· 145 145 try { 146 146 return fs 147 147 .readdirSync(workspaces) 148 - .filter((workspace) => workspace !== '.DS_Store'); 148 + .filter((workspace) => workspace !== '.DS_Store' && workspace !== 'Test_Plot'); 149 149 } catch (e: unknown) { 150 150 if ((e as NodeJS.ErrnoException).code === 'ENOENT') { 151 151 mkdirPathSync(workspaces); ··· 262 262 ); 263 263 264 264 ipcMain.handle( 265 - 'fs:storePyodideImage', 265 + 'fs:storePyodideImageSvg', 266 + (_event, title, imageTitle, svgContent: string) => { 267 + const dir = path.join(getWorkspaceDir(title), 'Results', 'Images'); 268 + mkdirPathSync(dir); 269 + return new Promise<void>((resolve, reject) => { 270 + fs.writeFile(path.join(dir, `${imageTitle}.svg`), svgContent, 'utf8', (err) => { 271 + if (err) reject(err); 272 + else resolve(); 273 + }); 274 + }); 275 + } 276 + ); 277 + 278 + ipcMain.handle( 279 + 'fs:storePyodideImagePng', 266 280 (_event, title, imageTitle, rawData: ArrayBuffer) => { 267 281 const dir = path.join(getWorkspaceDir(title), 'Results', 'Images'); 268 - const filename = `${imageTitle}.svg`; 269 282 mkdirPathSync(dir); 270 283 const buffer = Buffer.from(rawData); 271 284 return new Promise<void>((resolve, reject) => { 272 - fs.writeFile(path.join(dir, filename), buffer, (err) => { 285 + fs.writeFile(path.join(dir, `${imageTitle}.png`), buffer, (err) => { 273 286 if (err) reject(err); 274 287 else resolve(); 275 288 });
+9 -2
src/preload/index.ts
··· 88 88 session 89 89 ), 90 90 91 - storePyodideImage: ( 91 + storePyodideImageSvg: ( 92 + title: string, 93 + imageTitle: string, 94 + svgContent: string 95 + ): Promise<void> => 96 + ipcRenderer.invoke('fs:storePyodideImageSvg', title, imageTitle, svgContent), 97 + 98 + storePyodideImagePng: ( 92 99 title: string, 93 100 imageTitle: string, 94 101 rawData: ArrayBuffer 95 102 ): Promise<void> => 96 - ipcRenderer.invoke('fs:storePyodideImage', title, imageTitle, rawData), 103 + ipcRenderer.invoke('fs:storePyodideImagePng', title, imageTitle, rawData), 97 104 98 105 deleteWorkspaceDir: (title: string): Promise<void> => 99 106 ipcRenderer.invoke('fs:deleteWorkspaceDir', title),
+1
src/renderer/actions/pyodideActions.ts
··· 36 36 ReceiveMessage: createAction<any, 'RECEIVE_MESSAGE'>('RECEIVE_MESSAGE'), // Worker message event — shape is dynamic 37 37 // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 38 ReceiveError: createAction<any, 'RECEIVE_ERROR'>('RECEIVE_ERROR'), // Worker error event — shape is dynamic 39 + SetWorkerReady: createAction('SET_WORKER_READY'), 39 40 } as const; 40 41 41 42 export type PyodideActionType = ActionType<
+4 -2
src/renderer/components/HomeComponent/index.tsx
··· 63 63 topoPlot: { 64 64 [key: string]: string; 65 65 }; 66 + isWorkerReady: boolean; 66 67 } 67 68 68 69 interface State { ··· 321 322 <div> 322 323 <Button 323 324 variant="default" 325 + disabled={!this.props.isWorkerReady} 324 326 onClick={() => this.props.PyodideActions.LoadTopo()} 325 327 > 326 - Generate Plot 328 + {this.props.isWorkerReady ? 'Generate Plot' : 'Loading libraries…'} 327 329 </Button> 328 330 </div> 329 331 <div> 330 332 <PyodidePlotWidget 331 - title={'Test Plot'} 333 + title={'Test_Plot'} 332 334 imageTitle={`Test-Topoplot`} 333 335 plotMIMEBundle={this.props.topoPlot} 334 336 />
+55 -8
src/renderer/components/PyodidePlotWidget.tsx
··· 1 1 import React, { Component } from 'react'; 2 + import { toast } from 'react-toastify'; 2 3 import { Button } from './ui/button'; 3 - import { storePyodideImage } from '../utils/filesystem/storage'; 4 + import { storePyodideImageSvg, storePyodideImagePng } from '../utils/filesystem/storage'; 4 5 5 6 interface Props { 6 7 title: string; ··· 8 9 plotMIMEBundle: { 'image/svg+xml': string } | null | undefined; 9 10 } 10 11 12 + function svgToPngArrayBuffer(svg: string): Promise<ArrayBuffer> { 13 + return new Promise((resolve, reject) => { 14 + const blob = new Blob([svg], { type: 'image/svg+xml' }); 15 + const url = URL.createObjectURL(blob); 16 + const img = new Image(); 17 + img.onload = () => { 18 + const canvas = document.createElement('canvas'); 19 + canvas.width = img.naturalWidth; 20 + canvas.height = img.naturalHeight; 21 + const ctx = canvas.getContext('2d'); 22 + if (!ctx) { 23 + URL.revokeObjectURL(url); 24 + reject(new Error('No 2d context')); 25 + return; 26 + } 27 + ctx.drawImage(img, 0, 0); 28 + URL.revokeObjectURL(url); 29 + canvas.toBlob((pngBlob) => { 30 + if (!pngBlob) { reject(new Error('Canvas toBlob failed')); return; } 31 + pngBlob.arrayBuffer().then(resolve).catch(reject); 32 + }, 'image/png'); 33 + }; 34 + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG load failed')); }; 35 + img.src = url; 36 + }); 37 + } 38 + 11 39 export default class PyodidePlotWidget extends Component<Props> { 12 40 constructor(props: Props) { 13 41 super(props); 14 - this.handleSave = this.handleSave.bind(this); 42 + this.handleSaveSvg = this.handleSaveSvg.bind(this); 43 + this.handleSavePng = this.handleSavePng.bind(this); 15 44 } 16 45 17 - handleSave() { 46 + handleSaveSvg() { 18 47 const svg = this.props.plotMIMEBundle?.['image/svg+xml']; 19 48 if (!svg) return; 20 - const buf = Buffer.from(svg, 'utf8'); 21 - storePyodideImage(this.props.title, this.props.imageTitle, buf.buffer as ArrayBuffer); 49 + storePyodideImageSvg(this.props.title, this.props.imageTitle, svg) 50 + .then(() => toast.success(`Saved ${this.props.imageTitle}.svg`)) 51 + .catch((err) => toast.error(`Failed to save SVG: ${err.message}`)); 52 + } 53 + 54 + async handleSavePng() { 55 + const svg = this.props.plotMIMEBundle?.['image/svg+xml']; 56 + if (!svg) return; 57 + try { 58 + const arrayBuffer = await svgToPngArrayBuffer(svg); 59 + await storePyodideImagePng(this.props.title, this.props.imageTitle, arrayBuffer); 60 + toast.success(`Saved ${this.props.imageTitle}.png`); 61 + } catch (err: unknown) { 62 + toast.error(`Failed to save PNG: ${(err as Error).message}`); 63 + } 22 64 } 23 65 24 66 render() { ··· 31 73 src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} 32 74 alt={this.props.imageTitle} 33 75 /> 34 - <Button variant="default" size="sm" onClick={this.handleSave}> 35 - Save Image 36 - </Button> 76 + <div className="flex gap-2 mt-2"> 77 + <Button variant="outline" size="sm" onClick={this.handleSaveSvg}> 78 + Save as SVG 79 + </Button> 80 + <Button variant="outline" size="sm" onClick={this.handleSavePng}> 81 + Save as PNG 82 + </Button> 83 + </div> 37 84 </div> 38 85 ); 39 86 }
+1
src/renderer/epics/pyodideEpics.ts
··· 99 99 // results is a base64-encoded PNG string returned from Python. 100 100 const mimeBundle = results ? { 'image/svg+xml': results } : null; 101 101 switch (plotKey) { 102 + case 'ready': return of(PyodideActions.SetWorkerReady()); 102 103 case 'topo': return of(PyodideActions.SetTopoPlot(mimeBundle)); 103 104 case 'psd': return of(PyodideActions.SetPSDPlot(mimeBundle)); 104 105 case 'erp': return of(PyodideActions.SetERPPlot(mimeBundle));
+5
src/renderer/reducers/pyodideReducer.ts
··· 25 25 | null 26 26 | undefined; 27 27 readonly worker: Worker | null; 28 + readonly isWorkerReady: boolean; 28 29 } 29 30 30 31 const initialState: PyodideStateType = { ··· 34 35 topoPlot: null, 35 36 erpPlot: null, 36 37 worker: null, 38 + isWorkerReady: false, 37 39 }; 38 40 39 41 export default createReducer(initialState, (builder) => ··· 73 75 ...state, 74 76 erpPlot: action.payload, 75 77 }; 78 + }) 79 + .addCase(PyodideActions.SetWorkerReady, (state) => { 80 + return { ...state, isWorkerReady: true }; 76 81 }) 77 82 .addCase(ExperimentActions.ExperimentCleanup, (state, action) => { 78 83 return {
+8 -2
src/renderer/utils/filesystem/storage.ts
··· 50 50 ): Promise<void> => 51 51 api().storeBehavioralData(csv, title, subject, group, session); 52 52 53 - export const storePyodideImage = ( 53 + export const storePyodideImageSvg = ( 54 + title: string, 55 + imageTitle: string, 56 + svgContent: string 57 + ): Promise<void> => api().storePyodideImageSvg(title, imageTitle, svgContent); 58 + 59 + export const storePyodideImagePng = ( 54 60 title: string, 55 61 imageTitle: string, 56 62 rawData: ArrayBuffer 57 - ): Promise<void> => api().storePyodideImage(title, imageTitle, rawData); 63 + ): Promise<void> => api().storePyodideImagePng(title, imageTitle, rawData); 58 64 59 65 // ----------------------------------------------------------------------------------------------- 60 66 // Reading
+1
src/renderer/utils/webworker/index.ts
··· 32 32 export const loadUtils = async (worker: Worker) => 33 33 worker.postMessage({ 34 34 data: utilsPy, 35 + plotKey: 'ready', 35 36 }); 36 37 37 38 export const loadCSV = async (worker: Worker, csvArray: Array<unknown>) => {