WebR : Mise en œuvre et création d'un composant React/Next.js
Dans cet article, je décris l'implémentation d'une console R (via WebR) dans React/Next.js
Nous allons voir comment réaliser la console R suivante dans React/Next.js à l'aide de WebR :
Dessin d'un ❤
Script R permettant la réalisation d'un cœur sur un graphiqueAvant-propos
WebR s’exécute dans le navigateur du client. Ainsi, les performances varient d'un client à l'autre (connexion réseau, performances...) selon l'appareil (ordinateur/mobile).
Installation
Il est possible d'intégrer la librairie WebR des deux manières suivantes :
- Installation du module NPM sur votre serveur ;
- Utilisation d'un CDN.
J'ai personnellement choisit d'installer le module sur mon serveur via npm install webr
.
Choix des paramètres
WebR permet d’exécuter des scripts R depuis un navigateur. Pour mon composant, je vais donc lui passer les paramètres suivants :
- Un nom obligatoire qui s'affichera sous forme de titre ;
- Une description optionnelle qui pourra informer sur l'utilité du script ;
- Le code R, obligatoire ;
- Les fichiers d'entrée (optionnels) sur lesquels le script se base ;
- Les fichiers de sortie (optionnels) que le script pourrait générer ;
- Les bibliothèques (optionnelles) à installer.
Voici donc la définition de mon composant React :
(tsx)1export default function WebRConsole({ 2 name, // Titre pour l'affichage 3 description, // Description pour l'affichage 4 code, // Code R à executer 5 inputFiles = [], // Fichiers d'entrée 6 outputFiles = [], // Fichiers de sortie 7 library = [] // Bibliothèques à installer 8}: { 9 name: string, 10 description?: string, 11 code: string, 12 inputFiles?: FileDescription[], 13 outputFiles?: FileDescription[], 14 library?: string[] 15}) {
J'ai également crée le type FileDescription
pour permettre de donner :
- un nom ;
- une description ;
- le chemin vers le fichier dans le système de fichier WebR ;
- et enfin, un booléan qui indique si le fichier est nécessaire à l'exécution du script (pour les entrées) :
(tsx)1export type FileDescription = { 2 path: string; 3 displayName?: string; 4 description?: string; 5 optional?: boolean; 6};
Composant React final
Fichier : webrconsole.tsx (tsx)1'use client'; 2 3import { useState } from 'react'; 4import styles from './webrconsole.module.css'; 5 6export type FileDescription = { 7 path: string; 8 displayName?: string; 9 description?: string; 10 optional?: boolean; 11}; 12 13enum consoleStyles { 14 title = 'title', 15 consolePrompt = 'prompt', 16 consoleResult = 'result' 17} 18 19export default function WebRConsole({ 20 name, 21 description, 22 code, 23 inputFiles = [], 24 outputFiles = [], 25 library = [] 26}: { 27 name: string, 28 description?: string, 29 code: string, 30 inputFiles?: FileDescription[], 31 outputFiles?: FileDescription[], 32 library?: string[] 33}) { 34 const [result, setResult] = useState<string | null>(null); 35 const [downloadUrls, setDownloadUrls] = useState<Record<string, string>>(null); 36 const [inputFileBuffers, setInputFileBuffers] = useState<Record<string, File>>({}); 37 const [rConsoleOutput, setRConsoleOutput] = useState<{ content: string, style: consoleStyles}[]>([]); 38 39 function addLineToConsole(line: string, style: consoleStyles) { 40 setRConsoleOutput(prev => [...prev, 41 { content: line, style } 42 ]); 43 44 if (style === consoleStyles.title) { 45 setResult(line); 46 } 47 } 48 49 const runR = async () => { 50 51 setDownloadUrls(null) 52 setRConsoleOutput([]) 53 setResult(null) 54 55 try { 56 addLineToConsole('1/6 - Initialisation de WebR...', consoleStyles.title); 57 const { Console } = await import('webr'); 58 59 let consoleActive = false; 60 let resolvePromptReady: (() => void); 61 const waitForPrompt = () => new Promise<void>((resolve) => (resolvePromptReady = resolve)); 62 63 const webRConsole = new Console({ 64 stdout: (line: string) => { 65 if (consoleActive) addLineToConsole(line, consoleStyles.consoleResult); 66 }, 67 stderr: (line: string) => { 68 addLineToConsole('❌ ' + line, consoleStyles.consoleResult); 69 }, 70 prompt: () => { 71 resolvePromptReady(); 72 //addLineToConsole('> ____', consoleStyles.consoleResult); 73 } 74 }); 75 76 addLineToConsole('2/6 - Chargement des fichiers...', consoleStyles.title); 77 if(inputFiles) { 78 for (const fileDesc of inputFiles) { 79 const file = inputFileBuffers[fileDesc.path]; 80 if (file) { 81 const buffer = await file.arrayBuffer(); 82 const uint8Buffer = new Uint8Array(buffer); 83 webRConsole.webR.FS.writeFile(fileDesc.path, uint8Buffer); 84 } 85 } 86 } 87 88 addLineToConsole('3/6 - Installation des packages...', consoleStyles.title); 89 if(library) { 90 for (const pkg of library) { 91 await webRConsole.webR.installPackages(pkg); 92 } 93 } 94 95 addLineToConsole('4/6 - Attente de la console WebR...', consoleStyles.title); 96 webRConsole.run(); 97 await waitForPrompt(); 98 consoleActive = true; 99 100 addLineToConsole('5/6 - Exécution du script R...', consoleStyles.title); 101 for (const line of code.split('\n')) { 102 const trimmed = line.trim(); 103 if (trimmed.length > 0) { 104 addLineToConsole(trimmed, consoleStyles.consolePrompt); 105 webRConsole.stdin(trimmed + '\n'); 106 await waitForPrompt(); 107 } 108 } 109 110 consoleActive = false; 111 112 addLineToConsole('6/6 - Récupération des fichiers de sortie...', consoleStyles.title); 113 if(outputFiles) { 114 const newDownloadUrls: Record<string, string> = {}; 115 for (const fileDesc of outputFiles) { 116 try { 117 const fileData = await webRConsole.webR.FS.readFile(fileDesc.path); 118 const blob = new Blob([fileData]); 119 const url = URL.createObjectURL(blob); 120 newDownloadUrls[fileDesc.path] = url; 121 } 122 catch { 123 console.warn(`Le fichier de sortie '${fileDesc.path}' est introuvable.`); 124 } 125 } 126 setDownloadUrls(newDownloadUrls); 127 } 128 129 addLineToConsole('✅ Script terminé avec succès !', consoleStyles.title); 130 } catch (err: any) { 131 console.error('Erreur WebR :', err); 132 addLineToConsole('❌ Une erreur est survenue : ' + (err.message || err.toString()), consoleStyles.title); 133 setDownloadUrls({}); 134 } 135 }; 136 137 const handleFileChange = (path: string, file: File | null) => { 138 if (file) { 139 setInputFileBuffers(prev => ({ ...prev, [path]: file })); 140 } 141 }; 142 143 const requiredFilesReady = !inputFiles || inputFiles 144 .filter(file => !file.optional) 145 .every(file => inputFileBuffers[file.path]); 146 147 return ( 148 <div className={styles.webrconsole}> 149 <p className={styles.h2}>{name}</p> 150 {description && <span>{description}</span>} 151 152 <pre>{result}</pre> 153 154 {inputFiles && inputFiles.length > 0 && ( 155 <div> 156 <h3 className={styles.h3}>Paramètre{inputFiles.length > 1 ? 's' : ''}</h3> 157 158 <ul className={styles.inputList}> 159 {inputFiles.map(file => ( 160 <li key={file.path} className={styles.inputItem}> 161 <label className={styles.inputLabel}> 162 <span> 163 {file.displayName || file.path} 164 {file.optional && <em className={styles.optionalTag}> (optionnel)</em>} 165 </span> 166 167 <input 168 type="file" 169 className={styles.inputField} 170 onChange={e => handleFileChange(file.path, e.target.files?.[0] || null)} /> 171 </label> 172 {file.description && ( 173 <div className={styles.inputDescription}> 174 {file.description} 175 </div> 176 )} 177 </li> 178 ))} 179 </ul> 180 </div> 181 )} 182 183 {requiredFilesReady? ( 184 <button onClick={runR} className={styles.runButton}> 185 Exécuter le script 186 </button> 187 ) : ( 188 <button disabled className={styles.runButton + ' ' + styles.disabled} title="Veuillez d'abord fournir tous les fichiers requis"> 189 🚫 Fichiers manquants 190 </button> 191 )} 192 193 {rConsoleOutput.length > 0 && ( 194 <div className={styles.console}> 195 <h3>🖥️ Console R</h3> 196 {rConsoleOutput.map((line, idx) => ( 197 <div key={idx} className={styles[line.style]}>{line.content}</div> 198 ))} 199 </div> 200 )} 201 202 {outputFiles && outputFiles.length > 0 && downloadUrls !== null && ( 203 <div> 204 <h3 className={styles.h3}> 205 Résultat{outputFiles.length > 1 ? 's' : ''} 206 </h3> 207 208 <ul className={styles.inputList}> 209 {outputFiles.map(file => ( 210 <li key={file.path} className={styles.inputItem}> 211 <label className={styles.inputLabel}> 212 {file.displayName || file.path} 213 {downloadUrls && downloadUrls[file.path] ? ( 214 <> <a 215 href={downloadUrls[file.path]} 216 download={file.displayName || file.path} 217 > 218 📥 Télécharger 219 { file.path.endsWith('.png') && 220 <> <img className={styles.outputImage} src={downloadUrls[file.path]} /></> 221 } 222 </a> 223 </> 224 ) : ( 225 <>⚠️ Fichier non disponible</> 226 )} 227 </label> 228 {file.description && ( 229 <div className={styles.inputDescription}> 230 {file.description} 231 </div> 232 )} 233 </li> 234 ))} 235 </ul> 236 </div> 237 )} 238 239 <div className={styles.footer}>Généré via <a target="_blank" href="https://docs.r-wasm.org/webr/latest/">WebR</a></div> 240 </div> 241 ); 242}
Fichier : webrconsole.module.css (css)1.webrconsole { 2 padding: 10px; 3 border: 1px solid black; 4} 5 6.h2 { 7 font-size: 1.3em; 8 font-weight: bold; 9} 10.h3 { 11 margin-bottom: 0; 12} 13 14.console { 15 margin-top: 1rem; 16 padding: 1rem; 17 color: #FFF; 18 background-color: #111; 19 font-family: monospace; 20 white-space: pre-wrap; 21 max-height: 300; 22 overflow-y: auto; 23} 24 25.console .title { 26 font-weight: bold; 27} 28.console .prompt { 29 color: #0F0; 30} 31.console .prompt::before { 32 content: "> "; 33} 34 35.inputList { 36 list-style: none; 37 margin: 0; 38} 39 40.inputItem { 41 padding: 0.5rem 0; 42 border-bottom: 1px solid #ddd; 43} 44 45.inputLabel { 46 font-weight: bold; 47 font-size: 1rem; 48 color: #333; 49} 50 51.inputField { 52 margin-left: 0.7rem; 53} 54 55.inputDescription { 56 font-size: 0.9rem; 57 color: #666; 58} 59 60.runButton { 61 margin: 1rem 0; 62 padding: 0.5rem 1rem; 63 font-size: 1rem; 64 background-color: #1976d2; 65 color: white; 66 border: none; 67 cursor: pointer; 68 transition: background-color 0.2s ease-in-out; 69} 70 71.runButton:hover { 72 background-color: #1565c0; 73} 74 75.runButton.disabled { 76 background-color: #ccc; 77 color: #666; 78 cursor: not-allowed; 79} 80 81.webrconsole img, .outputImage { 82 max-width: 150px; 83 max-height: 100px; 84 vertical-align: middle; 85} 86 87.footer { 88 font-size: .8em; 89 text-align: right; 90}
― Valentin LORTET