|
| 1 | +// deck.gl |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | +// Copyright (c) vis.gl contributors |
| 4 | + |
| 5 | +import * as React from 'react'; |
| 6 | +import {useEffect, useMemo, useRef, useState} from 'react'; |
| 7 | +import Split from 'split.js'; |
| 8 | +import {Editor, type EditorHandle} from './editor'; |
| 9 | +import {PlayIcon, SpinnerIcon} from './icons'; |
| 10 | +import {FileDrop, type FileDropHandle} from './file-drop'; |
| 11 | +import { |
| 12 | + PyodideClient, |
| 13 | + type PreloadedFileConfig, |
| 14 | + type RunUpdate, |
| 15 | + type WorkerStatus |
| 16 | +} from './pyodide'; |
| 17 | +import { |
| 18 | + EditorPane, |
| 19 | + GlobalStyle, |
| 20 | + HtmlPane, |
| 21 | + LeftPane, |
| 22 | + OutputEmpty, |
| 23 | + RightPane, |
| 24 | + Root, |
| 25 | + RunButton, |
| 26 | + TextOutput, |
| 27 | + TextOutputPart as TextOutputPartView, |
| 28 | + TextPane, |
| 29 | + Toolbar, |
| 30 | + ToolbarLink, |
| 31 | + ToolbarTitle |
| 32 | +} from './styles'; |
| 33 | + |
| 34 | +const DEFAULT_SAMPLE = `\ |
| 35 | +import pydeck |
| 36 | +import pandas as pd |
| 37 | +
|
| 38 | +UK_ACCIDENTS_DATA = pd.read_csv(uploaded_files["heatmap-data.csv"]) |
| 39 | +print(UK_ACCIDENTS_DATA) |
| 40 | +
|
| 41 | +layer = pydeck.Layer( |
| 42 | + 'HexagonLayer', |
| 43 | + UK_ACCIDENTS_DATA, |
| 44 | + get_position=['lng', 'lat'], |
| 45 | + auto_highlight=True, |
| 46 | + elevation_scale=50, |
| 47 | + pickable=True, |
| 48 | + elevation_range=[0, 3000], |
| 49 | + extruded=True, |
| 50 | + coverage=1) |
| 51 | +
|
| 52 | +# Set the viewport location |
| 53 | +view_state = pydeck.ViewState( |
| 54 | + longitude=-1.415, |
| 55 | + latitude=52.2323, |
| 56 | + zoom=6, |
| 57 | + min_zoom=5, |
| 58 | + max_zoom=15, |
| 59 | + pitch=40.5, |
| 60 | + bearing=-27.36) |
| 61 | +
|
| 62 | +# Combined all of it and render a viewport |
| 63 | +r = pydeck.Deck(layers=[layer], initial_view_state=view_state) |
| 64 | +r.to_html('output.html') |
| 65 | +`; |
| 66 | + |
| 67 | +const PRELOADED_FILES: PreloadedFileConfig[] = [ |
| 68 | + { |
| 69 | + name: 'heatmap-data.csv', |
| 70 | + url: 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/3d-heatmap/heatmap-data.csv' |
| 71 | + } |
| 72 | +]; |
| 73 | + |
| 74 | +type AppStatus = WorkerStatus | 'Loading files...' | null; |
| 75 | +type TextOutputPart = { |
| 76 | + type: 'stderr' | 'stdout'; |
| 77 | + text: string; |
| 78 | +}; |
| 79 | +type OutputState = { |
| 80 | + html: string; |
| 81 | + textParts: TextOutputPart[]; |
| 82 | +}; |
| 83 | + |
| 84 | +export default function App({ |
| 85 | + preloadedFiles = PRELOADED_FILES, |
| 86 | + workerUrl |
| 87 | +}: { |
| 88 | + preloadedFiles?: PreloadedFileConfig[]; |
| 89 | + workerUrl: string | URL; |
| 90 | +}) { |
| 91 | + const pyodide = useMemo(() => new PyodideClient({workerUrl}), [workerUrl]); |
| 92 | + const editorRef = useRef<EditorHandle>(null); |
| 93 | + const fileDropRef = useRef<FileDropHandle>(null); |
| 94 | + const rootRef = useRef<HTMLDivElement | null>(null); |
| 95 | + const [output, setOutput] = useState<OutputState>({ |
| 96 | + html: '', |
| 97 | + textParts: [{text: 'Loading Python...', type: 'stdout'}] |
| 98 | + }); |
| 99 | + const [status, setStatus] = useState<AppStatus>('Loading packages...'); |
| 100 | + |
| 101 | + const setTextOutput = (value: unknown) => { |
| 102 | + setOutput({html: '', textParts: [{text: String(value), type: 'stdout'}]}); |
| 103 | + }; |
| 104 | + |
| 105 | + const formatRunTime = (timeElapsed: number) => `\nRun time: ${timeElapsed.toFixed(1)} ms`; |
| 106 | + |
| 107 | + const applyRunUpdate = (update: RunUpdate) => { |
| 108 | + if (update.status) { |
| 109 | + setStatus(update.status); |
| 110 | + } |
| 111 | + |
| 112 | + if (!update.stdout && !update.stderr) { |
| 113 | + return; |
| 114 | + } |
| 115 | + |
| 116 | + setOutput(currentOutput => { |
| 117 | + const nextParts = [...currentOutput.textParts]; |
| 118 | + |
| 119 | + const appendPart = (type: 'stderr' | 'stdout', text: string) => { |
| 120 | + if (!text) { |
| 121 | + return; |
| 122 | + } |
| 123 | + |
| 124 | + const lastPart = nextParts[nextParts.length - 1]; |
| 125 | + if (lastPart && lastPart.type === type) { |
| 126 | + lastPart.text += text; |
| 127 | + } else { |
| 128 | + nextParts.push({text, type}); |
| 129 | + } |
| 130 | + }; |
| 131 | + |
| 132 | + appendPart('stdout', update.stdout ?? ''); |
| 133 | + appendPart('stderr', update.stderr ?? ''); |
| 134 | + |
| 135 | + return {...currentOutput, textParts: nextParts}; |
| 136 | + }); |
| 137 | + }; |
| 138 | + |
| 139 | + const setRunOutput = async (value: {files: Blob[]; result: string; timeElapsed: number}) => { |
| 140 | + const htmlFile = value.files.find(file => file.type === 'text/html'); |
| 141 | + const html = htmlFile |
| 142 | + ? await htmlFile.text() |
| 143 | + : value.result.startsWith('<!DOCTYPE html>') |
| 144 | + ? value.result |
| 145 | + : ''; |
| 146 | + const timeText = formatRunTime(value.timeElapsed); |
| 147 | + |
| 148 | + setOutput(currentOutput => ({ |
| 149 | + html, |
| 150 | + textParts: |
| 151 | + currentOutput.textParts.length > 0 |
| 152 | + ? [...currentOutput.textParts, {text: timeText, type: 'stdout'}] |
| 153 | + : html |
| 154 | + ? [{text: timeText, type: 'stdout'}] |
| 155 | + : [{text: `${value.result}${timeText}`, type: 'stdout'}] |
| 156 | + })); |
| 157 | + }; |
| 158 | + |
| 159 | + useEffect(() => { |
| 160 | + let cancelled = false; |
| 161 | + |
| 162 | + pyodide |
| 163 | + .getVersion() |
| 164 | + .then(result => { |
| 165 | + if (!cancelled) { |
| 166 | + setTextOutput(result); |
| 167 | + } |
| 168 | + }) |
| 169 | + .catch(error => { |
| 170 | + if (!cancelled) { |
| 171 | + setTextOutput(error instanceof Error ? error.message : String(error)); |
| 172 | + } |
| 173 | + }); |
| 174 | + |
| 175 | + return () => { |
| 176 | + cancelled = true; |
| 177 | + }; |
| 178 | + }, []); |
| 179 | + |
| 180 | + useEffect(() => { |
| 181 | + if (!rootRef.current) { |
| 182 | + return undefined; |
| 183 | + } |
| 184 | + |
| 185 | + const leftPane = rootRef.current.querySelector<HTMLDivElement>('#left-pane'); |
| 186 | + const rightPane = rootRef.current.querySelector<HTMLDivElement>('#right-pane'); |
| 187 | + const htmlPane = rootRef.current.querySelector<HTMLElement>('.html-pane'); |
| 188 | + const textPane = rootRef.current.querySelector<HTMLElement>('.text-pane'); |
| 189 | + |
| 190 | + if (!leftPane || !rightPane || !htmlPane || !textPane) { |
| 191 | + return undefined; |
| 192 | + } |
| 193 | + |
| 194 | + const mainSplit = Split([leftPane, rightPane], { |
| 195 | + sizes: [40, 60], |
| 196 | + minSize: [320, 320], |
| 197 | + gutterSize: 4 |
| 198 | + }); |
| 199 | + const outputSplit = Split([htmlPane, textPane], { |
| 200 | + direction: 'vertical', |
| 201 | + sizes: [50, 50], |
| 202 | + minSize: [160, 160], |
| 203 | + gutterSize: 4 |
| 204 | + }); |
| 205 | + |
| 206 | + return () => { |
| 207 | + outputSplit.destroy(); |
| 208 | + mainSplit.destroy(); |
| 209 | + }; |
| 210 | + }, []); |
| 211 | + |
| 212 | + useEffect(() => { |
| 213 | + let cancelled = false; |
| 214 | + |
| 215 | + pyodide.ready |
| 216 | + .then(() => { |
| 217 | + if (!cancelled) { |
| 218 | + setStatus(currentStatus => |
| 219 | + currentStatus === 'Loading packages...' ? null : currentStatus |
| 220 | + ); |
| 221 | + } |
| 222 | + }) |
| 223 | + .catch(error => { |
| 224 | + if (!cancelled) { |
| 225 | + setTextOutput(error instanceof Error ? error.message : String(error)); |
| 226 | + } |
| 227 | + }); |
| 228 | + |
| 229 | + return () => { |
| 230 | + cancelled = true; |
| 231 | + }; |
| 232 | + }, []); |
| 233 | + |
| 234 | + const runCode = async () => { |
| 235 | + const source = editorRef.current?.getValue() ?? ''; |
| 236 | + editorRef.current?.showError(null); |
| 237 | + |
| 238 | + if (status) { |
| 239 | + return; |
| 240 | + } |
| 241 | + |
| 242 | + try { |
| 243 | + if (fileDropRef.current?.isLoading) { |
| 244 | + setStatus('Loading files...'); |
| 245 | + await fileDropRef.current.loading; |
| 246 | + } |
| 247 | + |
| 248 | + setOutput(currentOutput => ({...currentOutput, html: '', textParts: []})); |
| 249 | + const result = await pyodide.runPython(source, applyRunUpdate); |
| 250 | + await setRunOutput(result); |
| 251 | + } catch (error) { |
| 252 | + const message = error instanceof Error ? error.message : String(error); |
| 253 | + const lineMatch = message.match(/File "<exec>", line (\d+)/); |
| 254 | + |
| 255 | + if (lineMatch) { |
| 256 | + const errorStart = message.slice(lineMatch.index).search(/\n\w/); |
| 257 | + const errorMessage = message.slice(lineMatch.index! + errorStart + 1); |
| 258 | + editorRef.current?.showError({lineNumber: Number(lineMatch[1]), message: errorMessage}); |
| 259 | + } |
| 260 | + |
| 261 | + setTextOutput(message); |
| 262 | + } finally { |
| 263 | + setStatus(null); |
| 264 | + } |
| 265 | + }; |
| 266 | + |
| 267 | + return ( |
| 268 | + <> |
| 269 | + <GlobalStyle /> |
| 270 | + <Root id="root" ref={rootRef}> |
| 271 | + {/* Left Pane: Monaco Editor and Template Selector */} |
| 272 | + <LeftPane> |
| 273 | + <Toolbar> |
| 274 | + <ToolbarTitle> |
| 275 | + <strong>pydeck playground</strong> |
| 276 | + <ToolbarLink |
| 277 | + href="https://deckgl.readthedocs.io/en/latest/" |
| 278 | + rel="noreferrer" |
| 279 | + target="_blank" |
| 280 | + > |
| 281 | + Docs |
| 282 | + </ToolbarLink> |
| 283 | + </ToolbarTitle> |
| 284 | + <RunButton |
| 285 | + disabled={status !== null} |
| 286 | + onClick={() => { |
| 287 | + void runCode(); |
| 288 | + }} |
| 289 | + type="button" |
| 290 | + > |
| 291 | + {status ? ( |
| 292 | + <> |
| 293 | + <SpinnerIcon /> |
| 294 | + {status} |
| 295 | + </> |
| 296 | + ) : ( |
| 297 | + <PlayIcon /> |
| 298 | + )} |
| 299 | + </RunButton> |
| 300 | + </Toolbar> |
| 301 | + <EditorPane> |
| 302 | + <Editor defaultValue={DEFAULT_SAMPLE} ref={editorRef} /> |
| 303 | + </EditorPane> |
| 304 | + <FileDrop engine={pyodide} preloadedFiles={preloadedFiles} ref={fileDropRef} /> |
| 305 | + </LeftPane> |
| 306 | + |
| 307 | + {/* Right Pane: Output */} |
| 308 | + <RightPane> |
| 309 | + <HtmlPane> |
| 310 | + {output.html ? ( |
| 311 | + <iframe srcDoc={output.html} title="Python HTML output" /> |
| 312 | + ) : ( |
| 313 | + <OutputEmpty>No HTML output</OutputEmpty> |
| 314 | + )} |
| 315 | + </HtmlPane> |
| 316 | + <TextPane> |
| 317 | + <TextOutput> |
| 318 | + {output.textParts.map((part, index) => ( |
| 319 | + <TextOutputPartView $type={part.type} key={index}> |
| 320 | + {part.text} |
| 321 | + </TextOutputPartView> |
| 322 | + ))} |
| 323 | + </TextOutput> |
| 324 | + </TextPane> |
| 325 | + </RightPane> |
| 326 | + </Root> |
| 327 | + </> |
| 328 | + ); |
| 329 | +} |
0 commit comments