diff --git a/examples/pydeck/index.html b/examples/pydeck/index.html
new file mode 100644
index 00000000000..95b103b77ce
--- /dev/null
+++ b/examples/pydeck/index.html
@@ -0,0 +1,227 @@
+
+
+
+
+ pydeck Playground
+
+
+
+
+
+
+
+
+
diff --git a/examples/pydeck/package.json b/examples/pydeck/package.json
new file mode 100644
index 00000000000..6dc261d3e60
--- /dev/null
+++ b/examples/pydeck/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "deckgl-examples-playground",
+ "version": "0.0.0",
+ "private": true,
+ "license": "MIT",
+ "scripts": {
+ "start": "vite --open",
+ "start-local": "vite --config ../vite.config.local.mjs",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "@monaco-editor/react": "^4.4.6",
+ "monaco-editor": "^0.39.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0",
+ "react-dropzone": "^15.0.0",
+ "react-virtualized-auto-sizer": "^1.0.2",
+ "split.js": "^1.6.5"
+ },
+ "devDependencies": {
+ "typescript": "^4.6.0",
+ "vite": "^7.3.3"
+ }
+}
diff --git a/examples/pydeck/src/app.tsx b/examples/pydeck/src/app.tsx
new file mode 100644
index 00000000000..33083370a84
--- /dev/null
+++ b/examples/pydeck/src/app.tsx
@@ -0,0 +1,299 @@
+// deck.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import * as React from 'react';
+import {useEffect, useRef, useState} from 'react';
+import {createRoot} from 'react-dom/client';
+import Split from 'split.js';
+import {Editor, type EditorHandle} from './editor';
+import {PlayIcon, SpinnerIcon} from './icons';
+import {FileDrop, type FileDropHandle} from './file-drop';
+import {pyodide, type PreloadedFileConfig, type RunUpdate, type WorkerStatus} from './pyodide';
+
+const DEFAULT_SAMPLE = `\
+import pydeck
+import pandas as pd
+
+UK_ACCIDENTS_DATA = pd.read_csv(uploaded_files["heatmap-data.csv"])
+print(UK_ACCIDENTS_DATA)
+
+layer = pydeck.Layer(
+ 'HexagonLayer',
+ UK_ACCIDENTS_DATA,
+ get_position=['lng', 'lat'],
+ auto_highlight=True,
+ elevation_scale=50,
+ pickable=True,
+ elevation_range=[0, 3000],
+ extruded=True,
+ coverage=1)
+
+# Set the viewport location
+view_state = pydeck.ViewState(
+ longitude=-1.415,
+ latitude=52.2323,
+ zoom=6,
+ min_zoom=5,
+ max_zoom=15,
+ pitch=40.5,
+ bearing=-27.36)
+
+# Combined all of it and render a viewport
+r = pydeck.Deck(layers=[layer], initial_view_state=view_state)
+r.to_html('output.html')
+`;
+
+const PRELOADED_FILES: PreloadedFileConfig[] = [
+ {
+ name: 'heatmap-data.csv',
+ url: 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/3d-heatmap/heatmap-data.csv'
+ }
+];
+
+type AppStatus = WorkerStatus | 'Loading files...' | null;
+type TextOutputPart = {
+ type: 'stderr' | 'stdout';
+ text: string;
+};
+type OutputState = {
+ html: string;
+ textParts: TextOutputPart[];
+};
+
+export function App({preloadedFiles}: {preloadedFiles?: PreloadedFileConfig[]}) {
+ const editorRef = useRef(null);
+ const fileDropRef = useRef(null);
+ const rootRef = useRef(null);
+ const [output, setOutput] = useState({
+ html: '',
+ textParts: [{text: 'Loading Python...', type: 'stdout'}]
+ });
+ const [status, setStatus] = useState('Loading packages...');
+
+ const setTextOutput = (value: unknown) => {
+ setOutput({html: '', textParts: [{text: String(value), type: 'stdout'}]});
+ };
+
+ const formatRunTime = (timeElapsed: number) => `\nRun time: ${timeElapsed.toFixed(1)} ms`;
+
+ const applyRunUpdate = (update: RunUpdate) => {
+ if (update.status) {
+ setStatus(update.status);
+ }
+
+ if (!update.stdout && !update.stderr) {
+ return;
+ }
+
+ setOutput(currentOutput => {
+ const nextParts = [...currentOutput.textParts];
+
+ const appendPart = (type: 'stderr' | 'stdout', text: string) => {
+ if (!text) {
+ return;
+ }
+
+ const lastPart = nextParts[nextParts.length - 1];
+ if (lastPart && lastPart.type === type) {
+ lastPart.text += text;
+ } else {
+ nextParts.push({text, type});
+ }
+ };
+
+ appendPart('stdout', update.stdout ?? '');
+ appendPart('stderr', update.stderr ?? '');
+
+ return {...currentOutput, textParts: nextParts};
+ });
+ };
+
+ const setRunOutput = async (value: {files: Blob[]; result: string; timeElapsed: number}) => {
+ const htmlFile = value.files.find(file => file.type === 'text/html');
+ const html = htmlFile
+ ? await htmlFile.text()
+ : value.result.startsWith('')
+ ? value.result
+ : '';
+ const timeText = formatRunTime(value.timeElapsed);
+
+ setOutput(currentOutput => ({
+ html,
+ textParts:
+ currentOutput.textParts.length > 0
+ ? [...currentOutput.textParts, {text: timeText, type: 'stdout'}]
+ : html
+ ? [{text: timeText, type: 'stdout'}]
+ : [{text: `${value.result}${timeText}`, type: 'stdout'}]
+ }));
+ };
+
+ useEffect(() => {
+ let cancelled = false;
+
+ pyodide
+ .getVersion()
+ .then(result => {
+ if (!cancelled) {
+ setTextOutput(result);
+ }
+ })
+ .catch(error => {
+ if (!cancelled) {
+ setTextOutput(error instanceof Error ? error.message : String(error));
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!rootRef.current) {
+ return undefined;
+ }
+
+ const leftPane = rootRef.current.querySelector('#left-pane');
+ const rightPane = rootRef.current.querySelector('#right-pane');
+ const htmlPane = rootRef.current.querySelector('.html-pane');
+ const textPane = rootRef.current.querySelector('.text-pane');
+
+ if (!leftPane || !rightPane || !htmlPane || !textPane) {
+ return undefined;
+ }
+
+ const mainSplit = Split([leftPane, rightPane], {
+ sizes: [40, 60],
+ minSize: [320, 320],
+ gutterSize: 4
+ });
+ const outputSplit = Split([htmlPane, textPane], {
+ direction: 'vertical',
+ sizes: [50, 50],
+ minSize: [160, 160],
+ gutterSize: 4
+ });
+
+ return () => {
+ outputSplit.destroy();
+ mainSplit.destroy();
+ };
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ pyodide.ready
+ .then(() => {
+ if (!cancelled) {
+ setStatus(currentStatus =>
+ currentStatus === 'Loading packages...' ? null : currentStatus
+ );
+ }
+ })
+ .catch(error => {
+ if (!cancelled) {
+ setTextOutput(error instanceof Error ? error.message : String(error));
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const runCode = async () => {
+ const source = editorRef.current?.getValue() ?? '';
+ editorRef.current?.showError(null);
+
+ if (status) {
+ return;
+ }
+
+ try {
+ if (fileDropRef.current?.isLoading) {
+ setStatus('Loading files...');
+ await fileDropRef.current.loading;
+ }
+
+ setOutput(currentOutput => ({...currentOutput, html: '', textParts: []}));
+ const result = await pyodide.runPython(source, applyRunUpdate);
+ await setRunOutput(result);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ const lineMatch = message.match(/File "", line (\d+)/);
+
+ if (lineMatch) {
+ const errorStart = message.slice(lineMatch.index).search(/\n\w/);
+ const errorMessage = message.slice(lineMatch.index! + errorStart + 1);
+ editorRef.current?.showError({lineNumber: Number(lineMatch[1]), message: errorMessage});
+ }
+
+ setTextOutput(message);
+ } finally {
+ setStatus(null);
+ }
+ };
+
+ return (
+
+ {/* Left Pane: Monaco Editor and Template Selector */}
+
+
+ {/* Right Pane: Output */}
+
+
+ {output.html ? (
+
+ ) : (
+ No HTML output
+ )}
+
+
+
+ {output.textParts.map((part, index) => (
+
+ {part.text}
+
+ ))}
+
+
+
+
+ );
+}
+
+export function renderToDOM(container) {
+ createRoot(container).render();
+}
diff --git a/examples/pydeck/src/editor.tsx b/examples/pydeck/src/editor.tsx
new file mode 100644
index 00000000000..a4b8b917682
--- /dev/null
+++ b/examples/pydeck/src/editor.tsx
@@ -0,0 +1,80 @@
+import * as React from 'react';
+import {forwardRef, useImperativeHandle, useRef} from 'react';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import MonacoEditor from '@monaco-editor/react';
+
+export type EditorHandle = {
+ getValue: () => string;
+ showError: (error: {lineNumber: number; message: string} | null) => void;
+};
+
+export function clearErrorZone(editor: any, zoneIdRef: React.MutableRefObject) {
+ if (!editor || !zoneIdRef.current) {
+ return;
+ }
+
+ editor.changeViewZones(accessor => {
+ accessor.removeZone(zoneIdRef.current!);
+ });
+ zoneIdRef.current = null;
+}
+
+export const Editor = forwardRef(function Editor(
+ {defaultValue},
+ ref
+) {
+ const editorRef = useRef(null);
+ const errorZoneRef = useRef(null);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ getValue: () => editorRef.current?.getValue() ?? '',
+ showError: error => {
+ if (!editorRef.current) {
+ return;
+ }
+
+ clearErrorZone(editorRef.current, errorZoneRef);
+
+ if (!error) {
+ return;
+ }
+
+ const domNode = document.createElement('div');
+ domNode.className = 'inline-error';
+ const contentNode = document.createElement('div');
+ domNode.append(contentNode);
+ contentNode.textContent = error.message;
+
+ editorRef.current.changeViewZones(accessor => {
+ errorZoneRef.current = accessor.addZone({
+ afterLineNumber: error.lineNumber,
+ domNode,
+ heightInLines: error.message.split('\n').length + 1
+ });
+ });
+ editorRef.current.revealLineInCenter(error.lineNumber);
+ }
+ }),
+ []
+ );
+
+ return (
+
+ {({width, height}) => (
+ {
+ editorRef.current = editor;
+ }}
+ theme="light"
+ options={{scrollBeyondLastLine: false, minimap: {enabled: false}}}
+ />
+ )}
+
+ );
+});
diff --git a/examples/pydeck/src/file-drop.tsx b/examples/pydeck/src/file-drop.tsx
new file mode 100644
index 00000000000..e40aba039d5
--- /dev/null
+++ b/examples/pydeck/src/file-drop.tsx
@@ -0,0 +1,182 @@
+import React, {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState
+} from 'react';
+import Dropzone from 'react-dropzone';
+
+import {CopyIcon, SpinnerIcon} from './icons';
+import {pyodide, type PreloadedFileConfig, type UploadedFileRecord} from './pyodide';
+
+type FileDropProps = {
+ preloadedFiles?: PreloadedFileConfig[];
+};
+
+export type FileDropHandle = {
+ readonly isLoading: boolean;
+ readonly loading: Promise;
+};
+
+function createLoadingController() {
+ let resolve!: () => void;
+ const promise = new Promise(res => {
+ resolve = res;
+ });
+
+ return {promise, resolve};
+}
+
+export const FileDrop = forwardRef(function FileDrop(
+ {preloadedFiles = []}: FileDropProps,
+ ref
+) {
+ const [copiedFileName, setCopiedFileName] = useState(null);
+ const [error, setError] = useState(null);
+ const [files, setFiles] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const loadingControllerRef = useRef<{promise: Promise; resolve: () => void}>({
+ promise: Promise.resolve(),
+ resolve: () => {}
+ });
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ get isLoading() {
+ return isLoading;
+ },
+ get loading() {
+ return loadingControllerRef.current.promise;
+ }
+ }),
+ [isLoading]
+ );
+
+ const beginLoading = useCallback(() => {
+ loadingControllerRef.current = createLoadingController();
+ setIsLoading(true);
+ }, []);
+
+ const finishLoading = useCallback(() => {
+ loadingControllerRef.current.resolve();
+ setIsLoading(false);
+ }, []);
+
+ useEffect(() => {
+ beginLoading();
+
+ pyodide
+ .preloadFiles(preloadedFiles)
+ .then(result => {
+ setFiles(Object.values(result));
+ })
+ .catch(err => {
+ setError(err instanceof Error ? err.message : String(err));
+ })
+ .finally(() => {
+ finishLoading();
+ });
+ }, [preloadedFiles]);
+
+ const handleUpload = useCallback(
+ async (acceptedFiles: File[]) => {
+ if (acceptedFiles.length === 0) {
+ return;
+ }
+
+ beginLoading();
+ setError(null);
+
+ try {
+ const filesToUpload = await Promise.all(
+ acceptedFiles.map(async file => ({
+ buffer: await file.arrayBuffer(),
+ name: file.name,
+ size: file.size,
+ type: file.type
+ }))
+ );
+ const nextUploadedFiles = await pyodide.uploadFiles(filesToUpload);
+ setFiles(Object.values(nextUploadedFiles));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ finishLoading();
+ }
+ },
+ [beginLoading, finishLoading]
+ ) as (files: File[]) => void;
+
+ const handleCopy = useCallback(async (fileName: string) => {
+ const escapedFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ await navigator.clipboard.writeText(`uploaded_files["${escapedFileName}"]`);
+ setCopiedFileName(fileName);
+ window.setTimeout(() => {
+ setCopiedFileName(currentFileName => (currentFileName === fileName ? null : currentFileName));
+ }, 1200);
+ }, []);
+
+ return (
+
+ {({getRootProps, getInputProps}) => (
+
+
+ {files.map(file => (
+
+
+
{file.originalName}
+
+
+
{formatByteSize(file.size)}
+
+ ))}
+
+
+
+ {isLoading ? (
+
+ ) : (
+
Drag and drop some files here, or click to browse files
+ )}
+ {error &&
{error}
}
+
+
+ )}
+
+ );
+});
+
+function formatByteSize(bytes: number): string {
+ if (bytes < 1024) {
+ return `${bytes} B`;
+ }
+
+ const units = ['KB', 'MB', 'GB', 'TB'];
+ let value = bytes / 1024;
+ let unitIndex = 0;
+
+ while (value >= 1024 && unitIndex < units.length - 1) {
+ value /= 1024;
+ unitIndex += 1;
+ }
+
+ const roundedValue = value >= 10 ? value.toFixed(0) : value.toFixed(1);
+ return `${roundedValue} ${units[unitIndex]}`;
+}
diff --git a/examples/pydeck/src/icons.tsx b/examples/pydeck/src/icons.tsx
new file mode 100644
index 00000000000..ade3c65167f
--- /dev/null
+++ b/examples/pydeck/src/icons.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+
+type IconProps = React.SVGProps;
+
+export function PlayIcon(props: IconProps) {
+ return (
+
+ );
+}
+
+export function CopyIcon(props: IconProps) {
+ return (
+
+ );
+}
+
+export function SpinnerIcon(props: IconProps) {
+ return (
+
+ );
+}
diff --git a/examples/pydeck/src/pyodide-worker.ts b/examples/pydeck/src/pyodide-worker.ts
new file mode 100644
index 00000000000..bdab083966c
--- /dev/null
+++ b/examples/pydeck/src/pyodide-worker.ts
@@ -0,0 +1,424 @@
+const PYODIDE_CDN_URL = 'https://cdn.jsdelivr.net/pyodide/v314.0.0/full/pyodide.mjs';
+
+const UPLOADS_DIR = '/uploads';
+
+type UploadedFileRecord = {
+ originalName: string;
+ path: string;
+ storedName: string;
+ size: number;
+ type: string;
+ uploadedAt: string;
+};
+
+type PreloadedFileConfig = {
+ name: string;
+ url: string;
+};
+
+type UploadFileInput = {
+ buffer: ArrayBuffer;
+ name: string;
+ size: number;
+ type: string;
+};
+
+type WorkerRequest =
+ | {id: number; type: 'prepare-runtime'}
+ | {id: number; type: 'get-version'}
+ | {id: number; type: 'preload-files'; files: PreloadedFileConfig[]}
+ | {id: number; type: 'upload-files'; files: UploadFileInput[]}
+ | {id: number; type: 'run-python'; code: string};
+
+type WorkerStatus = 'Loading packages...' | 'Running...';
+type RunUpdate = {
+ status?: WorkerStatus;
+ stderr?: string;
+ stdout?: string;
+};
+type RunPythonResult = {
+ files: Array<{
+ buffer: ArrayBuffer;
+ name: string;
+ type: string;
+ }>;
+ result: string;
+ timeElapsed: number;
+};
+type FileSnapshotEntry = {
+ mtime: number;
+ size: number;
+};
+
+const knownModules = new Set(['pandas', 'pydeck']);
+const uploadedFiles: Record = {};
+let currentRunRequestId: number | null = null;
+let pendingStderr = '';
+let pendingStdout = '';
+let flushScheduled = false;
+let captureStreams = false;
+
+const pyodideReadyPromise = (async () => {
+ const {loadPyodide} = await import(/* @vite-ignore */ PYODIDE_CDN_URL);
+ const pyodide = await loadPyodide();
+ pyodide.FS.mkdirTree(UPLOADS_DIR);
+ pyodide.setStdout({
+ raw(charCode: number) {
+ if (!captureStreams) {
+ return;
+ }
+ pendingStdout += String.fromCharCode(charCode);
+ scheduleFlush();
+ }
+ });
+ pyodide.setStderr({
+ raw(charCode: number) {
+ if (!captureStreams) {
+ return;
+ }
+ pendingStderr += String.fromCharCode(charCode);
+ scheduleFlush();
+ }
+ });
+ pyodide.runPython(`
+import json
+uploaded_files = json.loads("{}")
+ `);
+ return pyodide;
+})();
+
+const runtimeReadyPromise = (async () => {
+ const pyodide = await pyodideReadyPromise;
+ await pyodide.loadPackage(['micropip', 'pandas']);
+ const micropip = pyodide.pyimport('micropip');
+ await micropip.install('pydeck');
+})();
+
+function postResult(id: number, result: unknown, transfer: Transferable[] = []) {
+ self.postMessage({id, type: 'result', result}, transfer);
+}
+
+function postError(id: number, error: string) {
+ self.postMessage({id, type: 'error', error});
+}
+
+function postUpdate(id: number, update: RunUpdate) {
+ self.postMessage({id, type: 'update', update});
+}
+
+function resetRunStreams() {
+ pendingStdout = '';
+ pendingStderr = '';
+ flushScheduled = false;
+}
+
+function flushPendingOutput() {
+ if (currentRunRequestId === null) {
+ resetRunStreams();
+ return;
+ }
+
+ const update: RunUpdate = {};
+
+ if (pendingStdout) {
+ update.stdout = pendingStdout;
+ pendingStdout = '';
+ }
+
+ if (pendingStderr) {
+ update.stderr = pendingStderr;
+ pendingStderr = '';
+ }
+
+ flushScheduled = false;
+
+ if (update.stdout || update.stderr) {
+ postUpdate(currentRunRequestId, update);
+ }
+}
+
+function scheduleFlush() {
+ if (flushScheduled) {
+ return;
+ }
+
+ flushScheduled = true;
+ queueMicrotask(() => {
+ flushPendingOutput();
+ });
+}
+
+function getUniqueStoredName(fileName: string) {
+ const lastDot = fileName.lastIndexOf('.');
+ const hasExtension = lastDot > 0;
+ const baseName = hasExtension ? fileName.slice(0, lastDot) : fileName;
+ const extension = hasExtension ? fileName.slice(lastDot) : '';
+ const safeBaseName = baseName.replace(/[^a-zA-Z0-9-_]+/g, '_') || 'upload';
+ const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+
+ return `${safeBaseName}__${uniqueSuffix}${extension}`;
+}
+
+async function syncUploadedFiles(pyodide: any) {
+ const uploadedFilePaths = Object.fromEntries(
+ Object.entries(uploadedFiles).map(([fileName, fileRecord]) => [fileName, fileRecord.path])
+ );
+
+ pyodide.globals.set('__uploaded_files_json', JSON.stringify(uploadedFilePaths));
+ pyodide.runPython(`
+import json
+uploaded_files = json.loads(__uploaded_files_json)
+ `);
+}
+
+function findImports(pyodide: any, code: string): string[] {
+ pyodide.globals.set('__source_for_import_scan', code);
+ const imports = pyodide.runPython(`
+from pyodide.code import find_imports
+
+find_imports(__source_for_import_scan)
+ `);
+
+ return Array.isArray(imports) ? imports : imports.toJs();
+}
+
+function getMimeType(path: string): string {
+ const normalizedPath = path.toLowerCase();
+
+ if (normalizedPath.endsWith('.html') || normalizedPath.endsWith('.htm')) {
+ return 'text/html';
+ }
+ if (normalizedPath.endsWith('.json')) {
+ return 'application/json';
+ }
+ if (normalizedPath.endsWith('.csv')) {
+ return 'text/csv';
+ }
+ if (
+ normalizedPath.endsWith('.txt') ||
+ normalizedPath.endsWith('.log') ||
+ normalizedPath.endsWith('.md')
+ ) {
+ return 'text/plain';
+ }
+ if (normalizedPath.endsWith('.svg')) {
+ return 'image/svg+xml';
+ }
+ if (normalizedPath.endsWith('.png')) {
+ return 'image/png';
+ }
+ if (normalizedPath.endsWith('.jpg') || normalizedPath.endsWith('.jpeg')) {
+ return 'image/jpeg';
+ }
+ if (normalizedPath.endsWith('.gif')) {
+ return 'image/gif';
+ }
+ if (normalizedPath.endsWith('.webp')) {
+ return 'image/webp';
+ }
+
+ return 'application/octet-stream';
+}
+
+function getBasename(path: string): string {
+ const segments = path.split('/');
+ return segments[segments.length - 1] || path;
+}
+
+function getMonitoredRoots(pyodide: any): string[] {
+ const cwd = String(
+ pyodide.runPython(`
+import os
+os.getcwd()
+ `)
+ );
+ return Array.from(new Set([cwd, UPLOADS_DIR]));
+}
+
+function snapshotDirectory(pyodide: any, path: string, snapshot: Map) {
+ let stat;
+
+ try {
+ stat = pyodide.FS.stat(path);
+ } catch {
+ return;
+ }
+
+ if (pyodide.FS.isFile(stat.mode)) {
+ snapshot.set(path, {
+ mtime: Number(stat.mtime),
+ size: Number(stat.size)
+ });
+ return;
+ }
+
+ if (!pyodide.FS.isDir(stat.mode)) {
+ return;
+ }
+
+ for (const entry of pyodide.FS.readdir(path)) {
+ if (entry === '.' || entry === '..') {
+ continue;
+ }
+
+ const childPath = path === '/' ? `/${entry}` : `${path}/${entry}`;
+ snapshotDirectory(pyodide, childPath, snapshot);
+ }
+}
+
+function takeFilesystemSnapshot(pyodide: any): Map {
+ const snapshot = new Map();
+
+ for (const root of getMonitoredRoots(pyodide)) {
+ snapshotDirectory(pyodide, root, snapshot);
+ }
+
+ return snapshot;
+}
+
+function getWrittenFiles(pyodide: any, before: Map) {
+ const after = takeFilesystemSnapshot(pyodide);
+ const files: RunPythonResult['files'] = [];
+ const transfer: Transferable[] = [];
+
+ for (const [path, entry] of after.entries()) {
+ const previousEntry = before.get(path);
+ const isChanged =
+ !previousEntry || previousEntry.mtime !== entry.mtime || previousEntry.size !== entry.size;
+
+ if (!isChanged) {
+ continue;
+ }
+
+ const bytes = pyodide.FS.readFile(path, {encoding: 'binary'});
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+
+ files.push({
+ buffer,
+ name: getBasename(path),
+ type: getMimeType(path)
+ });
+ transfer.push(buffer);
+ }
+
+ return {files, transfer};
+}
+
+async function registerUploadedFile(pyodide: any, file: UploadFileInput) {
+ const previousRecord = uploadedFiles[file.name];
+ const storedName = getUniqueStoredName(file.name);
+ const path = `${UPLOADS_DIR}/${storedName}`;
+
+ pyodide.FS.writeFile(path, new Uint8Array(file.buffer));
+
+ if (previousRecord) {
+ try {
+ pyodide.FS.unlink(previousRecord.path);
+ } catch {
+ // Ignore stale file cleanup failures and keep the latest upload registered.
+ }
+ }
+
+ uploadedFiles[file.name] = {
+ originalName: file.name,
+ path,
+ storedName,
+ size: file.size,
+ type: file.type,
+ uploadedAt: new Date().toISOString()
+ };
+}
+
+self.onmessage = async (event: MessageEvent) => {
+ const request = event.data;
+
+ try {
+ const pyodide = await pyodideReadyPromise;
+
+ switch (request.type) {
+ case 'prepare-runtime':
+ await runtimeReadyPromise;
+ postResult(request.id, true);
+ return;
+ case 'get-version': {
+ const result = await pyodide.runPython(`
+ import sys
+ sys.version
+ `);
+ postResult(request.id, String(result));
+ return;
+ }
+ case 'preload-files': {
+ for (const file of request.files) {
+ const response = await fetch(file.url);
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to preload ${file.name}: ${response.status} ${response.statusText}`
+ );
+ }
+
+ const buffer = await response.arrayBuffer();
+ await registerUploadedFile(pyodide, {
+ buffer,
+ name: file.name,
+ size: buffer.byteLength,
+ type: response.headers.get('content-type') ?? ''
+ });
+ }
+
+ await syncUploadedFiles(pyodide);
+ postResult(request.id, uploadedFiles);
+ return;
+ }
+ case 'upload-files': {
+ for (const file of request.files) {
+ await registerUploadedFile(pyodide, file);
+ }
+
+ await syncUploadedFiles(pyodide);
+ postResult(request.id, uploadedFiles);
+ return;
+ }
+ case 'run-python': {
+ const startedAt = performance.now();
+ currentRunRequestId = request.id;
+ resetRunStreams();
+ postUpdate(request.id, {status: 'Loading packages...'});
+ await runtimeReadyPromise;
+ const importedModules = findImports(pyodide, request.code);
+ const hasUnknownImports = importedModules.some(moduleName => !knownModules.has(moduleName));
+
+ if (hasUnknownImports) {
+ captureStreams = false;
+ await pyodide.loadPackagesFromImports(request.code);
+ importedModules.forEach(moduleName => {
+ knownModules.add(moduleName);
+ });
+ }
+ postUpdate(request.id, {status: 'Running...'});
+ captureStreams = true;
+ const filesystemBeforeRun = takeFilesystemSnapshot(pyodide);
+ const result = await pyodide.runPython(request.code);
+ flushPendingOutput();
+ const writtenFiles = getWrittenFiles(pyodide, filesystemBeforeRun);
+ const payload: RunPythonResult = {
+ files: writtenFiles.files,
+ result: String(result),
+ timeElapsed: performance.now() - startedAt
+ };
+ captureStreams = false;
+ currentRunRequestId = null;
+ postResult(request.id, payload, writtenFiles.transfer);
+ return;
+ }
+ default:
+ throw new Error('Unknown worker request');
+ }
+ } catch (error) {
+ captureStreams = false;
+ flushPendingOutput();
+ currentRunRequestId = null;
+ postError(request.id, error instanceof Error ? error.message : String(error));
+ }
+};
diff --git a/examples/pydeck/src/pyodide.ts b/examples/pydeck/src/pyodide.ts
new file mode 100644
index 00000000000..b7f53a67ee7
--- /dev/null
+++ b/examples/pydeck/src/pyodide.ts
@@ -0,0 +1,136 @@
+export type UploadedFileRecord = {
+ originalName: string;
+ path: string;
+ storedName: string;
+ size: number;
+ type: string;
+ uploadedAt: string;
+};
+
+export type PreloadedFileConfig = {
+ name: string;
+ url: string;
+};
+
+export type WorkerStatus = 'Loading packages...' | 'Running...';
+export type RunUpdate = {
+ status?: WorkerStatus;
+ stderr?: string;
+ stdout?: string;
+};
+export type RunPythonResult = {
+ files: Blob[];
+ result: string;
+ timeElapsed: number;
+};
+type WorkerTransferredFile = {
+ buffer: ArrayBuffer;
+ name: string;
+ type: string;
+};
+type WorkerRunPythonResult = {
+ files: WorkerTransferredFile[];
+ result: string;
+ timeElapsed: number;
+};
+
+type UploadFileInput = {
+ buffer: ArrayBuffer;
+ name: string;
+ size: number;
+ type: string;
+};
+
+type PendingRequest = {
+ onUpdate?: (update: RunUpdate) => void;
+ reject: (reason?: unknown) => void;
+ resolve: (value: unknown) => void;
+};
+
+type WorkerMessage =
+ | {id: number; type: 'result'; result: unknown}
+ | {id: number; type: 'error'; error: string}
+ | {id: number; type: 'update'; update: RunUpdate};
+
+function createWorker() {
+ return new Worker(new URL('./pyodide-worker.ts', import.meta.url), {type: 'module'});
+}
+
+class PyodideClient {
+ private nextRequestId = 0;
+ private readonly pendingRequests = new Map();
+ private readonly worker = createWorker();
+
+ readonly ready: Promise;
+
+ constructor() {
+ this.worker.onmessage = (event: MessageEvent) => {
+ const message = event.data;
+ const pending = this.pendingRequests.get(message.id);
+
+ if (!pending) {
+ return;
+ }
+
+ if (message.type === 'update') {
+ pending.onUpdate?.(message.update);
+ return;
+ }
+
+ this.pendingRequests.delete(message.id);
+
+ if (message.type === 'error') {
+ pending.reject(new Error(message.error));
+ return;
+ }
+
+ pending.resolve(message.result);
+ };
+
+ this.ready = this.request({type: 'prepare-runtime'});
+ }
+
+ async getVersion() {
+ return this.request({type: 'get-version'});
+ }
+
+ async preloadFiles(files: PreloadedFileConfig[]) {
+ return this.request>({files, type: 'preload-files'});
+ }
+
+ async runPython(code: string, onUpdate: (update: RunUpdate) => void) {
+ const result = await this.request(
+ {code, type: 'run-python'},
+ {onUpdate}
+ );
+
+ return {
+ files: result.files.map(file => new Blob([file.buffer], {type: file.type})),
+ result: result.result,
+ timeElapsed: result.timeElapsed
+ };
+ }
+
+ async uploadFiles(files: UploadFileInput[]) {
+ const transfer = files.map(file => file.buffer);
+ return this.request>(
+ {files, type: 'upload-files'},
+ {transfer}
+ );
+ }
+
+ private request(
+ payload: Record,
+ options?: {onUpdate?: (update: RunUpdate) => void; transfer?: Transferable[]}
+ ): Promise {
+ const id = this.nextRequestId++;
+
+ return new Promise((resolve, reject) => {
+ this.pendingRequests.set(id, {onUpdate: options?.onUpdate, reject, resolve});
+
+ this.worker.postMessage({id, ...payload}, options?.transfer ?? []);
+ });
+ }
+}
+
+export const pyodide = new PyodideClient();
diff --git a/examples/pydeck/vite.config.js b/examples/pydeck/vite.config.js
new file mode 100644
index 00000000000..2be7d5072b3
--- /dev/null
+++ b/examples/pydeck/vite.config.js
@@ -0,0 +1,7 @@
+// deck.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+export default {
+ base: './'
+};