WebR : Mise en œuvre et création d'un composant React/Next.js

Cet article est accessible en avance mais peut contenir des parties incomplètes.
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 graphique

Avant-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

Cet article vous a plu ? N'hésitez pas à le partager:

Découvrir d’autres articles