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.

update the test plot functionality

+83 -51
+4 -4
.llms/CLAUDE.md
··· 15 15 - **Bundler**: electron-vite / Vite 16 16 - **State**: Redux Toolkit + redux-observable (RxJS epics) 17 17 - **Language**: TypeScript (strict) 18 - - **Styling**: Semantic UI React + SCSS 19 - - **Testing**: Jest 18 + - **Styling**: Tailwind CSS + shadcn/ui (components in `src/renderer/components/ui/`) 19 + - **Testing**: Vitest 20 20 - **Linting**: ESLint + Prettier (single quotes, ES5 trailing commas) 21 21 22 22 ## Key Directories ··· 30 30 ```bash 31 31 npm run dev # Start dev server (patches deps first) 32 32 npm run build # Build all processes 33 - npm test # Run Jest tests 33 + npm test # Run Vitest tests 34 34 npm run typecheck # TypeScript check (no emit) 35 35 npm run lint # ESLint 36 36 npm run lint-fix # ESLint + Prettier auto-fix ··· 45 45 - Keep Electron main/renderer separation strict — use preload IPC bridges 46 46 47 47 ## Out of Scope 48 - - Do not modify `src/renderer/utils/webworker/src/` directly; it is managed by `InstallPyodide.js` 48 + - Do not modify `src/renderer/utils/webworker/src/` directly; it is managed by `internals/scripts/InstallPyodide.mjs` (Pyodide runtime) and `internals/scripts/InstallMNE.mjs` (scientific packages) 49 49 - Do not alter `electron-builder` publish config without confirming release intent 50 50 51 51 ## LLM Context
+23 -31
src/renderer/epics/pyodideEpics.ts
··· 1 1 import { combineEpics, Epic } from 'redux-observable'; 2 - import { fromEvent, Observable, ObservableInput, of } from 'rxjs'; 2 + import { EMPTY, fromEvent, Observable, ObservableInput, of } from 'rxjs'; 3 3 import { map, mergeMap, tap, pluck, filter } from 'rxjs/operators'; 4 4 import { toast } from 'react-toastify'; 5 5 import { isActionOf } from '../utils/redux'; ··· 87 87 filter(isActionOf(PyodideActions.SetPyodideWorker)), 88 88 pluck('payload'), 89 89 // eslint-disable-next-line @typescript-eslint/no-explicit-any 90 - mergeMap<Worker, Observable<any>>((worker) => { 91 - // Worker message event — MessageEvent data shape is dynamic 92 - return fromEvent(worker, 'message'); 93 - }), 94 - tap((e) => { 95 - console.log(e); 96 - const { results, error } = e.data; 97 - 98 - if (results && !error) { 99 - toast.error(`Pyodide: ${results}`); 100 - } else if (error) { 90 + mergeMap<Worker, Observable<any>>((worker) => fromEvent(worker, 'message')), 91 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 92 + mergeMap<any, Observable<any>>((e) => { 93 + const { results, error, plotKey } = e.data; 94 + if (error) { 101 95 toast.error(`Pyodide: ${error}`); 96 + return of(PyodideActions.ReceiveError(error)); 102 97 } 103 - }), 104 - map(PyodideActions.ReceiveMessage) 98 + // Route plot results to the appropriate Redux state slot. 99 + // results is a base64-encoded PNG string returned from Python. 100 + const mimeBundle = results ? { 'image/png': results } : null; 101 + switch (plotKey) { 102 + case 'topo': return of(PyodideActions.SetTopoPlot(mimeBundle)); 103 + case 'psd': return of(PyodideActions.SetPSDPlot(mimeBundle)); 104 + case 'erp': return of(PyodideActions.SetERPPlot(mimeBundle)); 105 + default: return of(PyodideActions.ReceiveMessage(e.data)); 106 + } 107 + }) 105 108 ); 106 109 107 110 const loadEpochsEpic: Epic<PyodideActionType, PyodideActionType, RootState> = ( ··· 225 228 ) => 226 229 action$.pipe( 227 230 filter(isActionOf(PyodideActions.LoadPSD)), 228 - mergeMap(() => plotPSD(state$.value.pyodide.worker!)), 229 - map(PyodideActions.SetPSDPlot) 231 + tap(() => plotPSD(state$.value.pyodide.worker!)), 232 + mergeMap(() => EMPTY) 230 233 ); 231 234 232 235 const loadTopoEpic: Epic<PyodideActionType, PyodideActionType, RootState> = ( ··· 235 238 ) => 236 239 action$.pipe( 237 240 filter(isActionOf(PyodideActions.LoadTopo)), 238 - // mergeMap(plotTopoMap), 239 - mergeMap(() => plotTestPlot(state$.value.pyodide.worker!)), 240 - tap((e) => console.log('received topo map: ', e)), 241 - mergeMap((topoPlot) => 242 - of( 243 - PyodideActions.SetTopoPlot(topoPlot) 244 - // PyodideActions.LoadERP( 245 - // state$.value.device.deviceType === DEVICES.EMOTIV 246 - // ? EMOTIV_CHANNELS[0] 247 - // : MUSE_CHANNELS[0] 248 - // ) 249 - ) 250 - ) 241 + tap(() => plotTestPlot(state$.value.pyodide.worker!)), 242 + mergeMap(() => EMPTY) 251 243 ); 252 244 253 245 const loadERPEpic: Epic<PyodideActionType, PyodideActionType, RootState> = ( ··· 273 265 ); 274 266 return parseInt(EMOTIV_CHANNELS[0], 10); 275 267 }), 276 - mergeMap((chanIndex) => plotERP(state$.value.pyodide.worker!, chanIndex)), 277 - map(PyodideActions.SetERPPlot) 268 + tap((chanIndex) => plotERP(state$.value.pyodide.worker!, chanIndex)), 269 + mergeMap(() => EMPTY) 278 270 ); 279 271 280 272 export default combineEpics(
+45 -12
src/renderer/utils/webworker/index.ts
··· 112 112 }; 113 113 114 114 export const plotPSD = async (worker: Worker) => { 115 - return worker.postMessage({ data: `raw.plot_psd(fmin=1, fmax=30)` }); 115 + worker.postMessage({ 116 + plotKey: 'psd', 117 + data: [ 118 + 'import io, base64', 119 + '_fig = raw.plot_psd(fmin=1, fmax=30, show=False)', 120 + '_buf = io.BytesIO()', 121 + '_fig.savefig(_buf, format="png", bbox_inches="tight")', 122 + 'plt.close(_fig)', 123 + 'base64.b64encode(_buf.getvalue()).decode()', 124 + ].join('\n'), 125 + }); 116 126 }; 117 127 118 128 export const plotTopoMap = async (worker: Worker) => { 119 - return worker.postMessage({ 120 - data: `plot_topo(clean_epochs, conditions)`, 129 + worker.postMessage({ 130 + plotKey: 'topo', 131 + data: [ 132 + 'import io, base64', 133 + '_fig = plot_topo(clean_epochs, conditions)', 134 + '_buf = io.BytesIO()', 135 + '_fig.savefig(_buf, format="png", bbox_inches="tight")', 136 + 'plt.close(_fig)', 137 + 'base64.b64encode(_buf.getvalue()).decode()', 138 + ].join('\n'), 121 139 }); 122 140 }; 123 141 124 142 export const plotTestPlot = async (worker: Worker | null) => { 125 - if (!worker) { 126 - return; 127 - } 128 - return worker.postMessage({ 129 - // data: `import matplotlib.pyplot as plt; fig= plt.plot([1,2,3,4])`, 130 - data: `sum([1,2,3,4])` 143 + if (!worker) return; 144 + worker.postMessage({ 145 + plotKey: 'topo', 146 + data: [ 147 + 'import io, base64', 148 + 'import matplotlib.pyplot as plt', 149 + '_fig, _ax = plt.subplots()', 150 + '_ax.plot([1, 2, 3, 4], [1, 4, 2, 3])', 151 + '_ax.set_title("Test Plot")', 152 + '_buf = io.BytesIO()', 153 + '_fig.savefig(_buf, format="png", bbox_inches="tight")', 154 + 'plt.close(_fig)', 155 + 'base64.b64encode(_buf.getvalue()).decode()', 156 + ].join('\n'), 131 157 }); 132 158 }; 133 159 134 160 export const plotERP = async (worker: Worker, channelIndex: number) => { 135 - return worker.postMessage({ 136 - data: `X, y = plot_conditions(clean_epochs, ch_ind=${channelIndex}, conditions=conditions, 137 - ci=97.5, n_boot=1000, title='', diff_waveform=None)`, 161 + worker.postMessage({ 162 + plotKey: 'erp', 163 + data: [ 164 + 'import io, base64', 165 + `_fig, _ = plot_conditions(clean_epochs, ch_ind=${channelIndex}, conditions=conditions, ci=97.5, n_boot=1000, title='', diff_waveform=None)`, 166 + '_buf = io.BytesIO()', 167 + '_fig.savefig(_buf, format="png", bbox_inches="tight")', 168 + 'plt.close(_fig)', 169 + 'base64.b64encode(_buf.getvalue()).decode()', 170 + ].join('\n'), 138 171 }); 139 172 }; 140 173
+1 -1
src/renderer/utils/webworker/patches.py
··· 25 25 try: 26 26 patch() 27 27 except Exception as err: 28 - warnings.warn("faield to apply patch", patch, err) 28 + warnings.warn("failed to apply patch", patch, err)
+10 -3
src/renderer/utils/webworker/webworker.js
··· 56 56 { checkIntegrity: false } 57 57 ); 58 58 59 + // Set matplotlib backend before any imports so it takes effect on first import. 60 + // Must be 'agg' (non-interactive, buffer-based) — web workers have no DOM, 61 + // so WebAgg fails with "cannot import name 'document' from 'js'". 62 + await pyodide.runPythonAsync( 63 + 'import os; os.environ["MPLBACKEND"] = "agg"' 64 + ); 65 + 59 66 // Load micropip so we can install MNE and its pure-Python deps. 60 67 await pyodide.loadPackage('micropip', { checkIntegrity: false }); 61 68 const micropip = pyodide.pyimport('micropip'); ··· 81 88 return; 82 89 } 83 90 84 - const { data, ...context } = event.data; 91 + const { data, plotKey, ...context } = event.data; 85 92 86 93 // Expose context values as globals so Python can access them via the js module. 87 94 for (const [key, value] of Object.entries(context)) { ··· 89 96 } 90 97 91 98 try { 92 - self.postMessage({ results: await pyodide.runPythonAsync(data) }); 99 + self.postMessage({ results: await pyodide.runPythonAsync(data), plotKey }); 93 100 } catch (error) { 94 - self.postMessage({ error: error.message }); 101 + self.postMessage({ error: error.message, plotKey }); 95 102 } 96 103 };