Skip to content

Instantly share code, notes, and snippets.

@etpinard
Last active February 3, 2023 22:19
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save etpinard/b1bda979e3cf16011b2cedddd62fe965 to your computer and use it in GitHub Desktop.
Save etpinard/b1bda979e3cf16011b2cedddd62fe965 to your computer and use it in GitHub Desktop.
Use electron to export plotly.js graphs as images by making requests to an HTTP server.
"Use electron to export plotly.js graphs as images by making requests to an HTTP server."
node_modules
npm-debug.log*
*.png

electron-plotly.js-server

Use electron to export plotly.js graphs as images by making requests to an HTTP server.

How to run this thing?

  • npm i
  • npm start & (starts server)
  • npm test (makes request to server, saves image from response)

If you're on Ubuntu (or maybe some other Linux distro) and you have xvfb installed, you can run this things headlessly (no joke) with:

npm run start:headless

and see results in 0.png.

You can also pass as argument any URL to a plotly "data"/"layout" JSON or the name of the plotly.js test mock you'd like to export. For example:

npm test -- 12 20 gl3d_bunny geo_first gl2d_parcoords

How does this thing work?

Debugging

This gist uses electron@1.7.2 which has an --inspect flag to debug the main.js process using the Chrome devtools.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>plotly.js in electron</title>
<script src="node_modules/plotly.js/dist/plotly.js"></script>
</head>
<body>
<script src="renderer.js"></script>
</body>
</html>
const {app, BrowserWindow} = require('electron')
const {ipcMain} = require('electron')
const http = require('http')
const textBody = require('body')
const hat = require('hat')
const minimist = require('minimist')
const argv = minimist(process.argv.slice(2), {
string: ['port', 'debug'],
'default': {
port: 8000,
debug: !!process.env.DEBUG
}
})
const PORT = argv.port
const DEBUG = argv.debug
let server
let win
// to generate WebGL in headless environments
app.commandLine.appendSwitch('ignore-gpu-blacklist')
app.on('ready', () => {
win = new BrowserWindow({
height: 1024,
width: 1024
})
server = createServer(win)
server.on('error', (err) => {
console.warn(err)
app.quit()
})
win.loadURL(`file://${__dirname}/index.html`)
if (DEBUG) {
win.openDevTools()
}
win.on('closed', () => {
win = null
})
process.on('close', () => {
win.close()
})
win.webContents.once('did-finish-load', () => {
server.listen(PORT, () => {
console.log(`listening on port ${PORT}`)
})
})
})
function createServer (win) {
return http.createServer((req, res) => {
const uid = hat()
if (req.url === '/ping') {
res.writeHead(200, {'Content-Type': 'text/plain'})
return res.end('pong\n')
}
textBody(req, {limit: 1e9}, (err, body) => {
if (err) return console.warn(err)
const fig = JSON.parse(body)
win.webContents.send('fig', fig, uid)
})
ipcMain.once(uid, (event, info) => {
switch (info.code) {
case 200:
const buf = Buffer.from(info.imgData, 'base64')
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Length': info.imgData.length
})
if (res.write(buf)) {
res.end()
} else {
res.once('drain', () => res.end())
}
break
case 525:
res.writeHead(525, {'Content-Type': 'text/plain'})
res.end('plotly.js error')
break
}
})
})
}
{
"name": "electron-plotly.js-server",
"version": "1.0.0",
"description": "Use electron to export plotly.js graphs as images by making requests to an HTTP server",
"main": "main.js",
"scripts": {
"start": "electron .",
"start:headless": "export DISPLAY=:99 && xvfb-run -a -e /dev/stdout --server-args='-screen 0, 1024x1024x24' npm start",
"debug:renderer": "DEBUG=1 electron .",
"debug:main": "DEBUG=1 electron --inspect .",
"test": "tap test.js",
"clean": "rm *.png"
},
"keywords": [],
"author": "Étienne Tétreault-Pinard",
"license": "MIT",
"dependencies": {
"body": "^5.1.0",
"electron": "^1.7.5",
"hat": "0.0.3",
"minimist": "^1.2.0",
"plotly.js": "^1.25.2"
},
"devDependencies": {
"request": "^2.81.0",
"spectron": "^3.7.2",
"streamtest": "^1.2.2",
"tap": "^10.7.1"
}
}
/* global Plotly:false */
const {ipcRenderer} = require('electron')
ipcRenderer.on('fig', (event, fig, uid) => {
const gd = document.createElement('div')
document.body.appendChild(gd)
Plotly.newPlot(gd, fig)
.then(gd => Plotly.toImage(gd))
.then(imgData => {
ipcRenderer.send(uid, {
code: 200,
imgData: imgData.replace(/^data:image\/\w+;base64,/, '')
})
Plotly.purge(gd)
document.body.removeChild(gd)
})
.catch(err => {
ipcRenderer.send(uid, {
code: 525,
msg: JSON.stringify(err, ['message', 'arguments', 'type', 'name'])
})
})
})
const tap = require('tap')
const Application = require('spectron').Application
const electronPath = require('electron')
const fs = require('fs')
const request = require('request')
const StreamTest = require('streamtest').v2
const PORT = 9191
const SERVER_URL = 'http://localhost:' + PORT
const MOCK_URL_BASE = 'https://raw.githubusercontent.com/plotly/plotly.js/master/test/image/mocks'
const MOCK_LIST = ['10', 'gl3d_bunny', 'geo_first']
const app = new Application({
path: electronPath,
args: [`${__dirname}`, '--port', PORT]
})
tap.test('should launch', t => {
app.start().then(() => {
app.client.getWindowCount().then(cnt => {
t.equal(cnt, 1)
t.end()
})
})
})
tap.test('should reply pong to ping post', t => {
const ws = StreamTest.toText((err, txt) => {
if (err) t.fail(err)
t.equal(txt, 'pong\n')
t.end()
})
request({
method: 'POST',
url: SERVER_URL + '/ping'
})
.on('response', res => t.equal(res.statusCode, 200))
.on('error', t.fail)
.pipe(ws)
})
MOCK_LIST.forEach(m => {
tap.test(`should work for ${m}`, t => {
const figUrl = `${MOCK_URL_BASE}/${m}.json`
const output = `${__dirname}/${m}.png`
if (fs.existsSync(output)) {
fs.unlinkSync(output)
}
t.notOk(fs.existsSync(output))
const ws = fs.createWriteStream(output)
ws.on('finish', () => {
t.ok(fs.existsSync(output))
t.end()
})
request.get(figUrl, (err, res, body) => {
if (err) t.fail(err)
if (res.statusCode !== 200) t.fail('fig not found')
t.ok(typeof body, 'string')
request({
method: 'POST',
url: SERVER_URL + '/',
body: body
})
.on('response', res => t.equal(res.statusCode, 200))
.on('error', t.fail)
.pipe(ws)
})
})
})
tap.test('should teardown', t => {
app.stop().then(t.end)
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment