Как-то раз, за обсуждением Idris ЯП с коллегой, зашла речь про графики plotly. Очередное подделие вызвало у меня неподдельную усмешку. Ещё бы! Что можно ожидать от ещё одного велосипедного чарта, содержащего в себе d3 и webgl? Правильно, валялся бы этот велосипед в бездне гитхаба, если бы у меня получилось установить chartjs-node. Но что-то мой химерный ubuntu-windows терминал, о котором я как раз недавно сделал обзор на моё блоге, не давал пакету установится, выплёвывая свою ругань на node-gyp. Python’ячьи ошибки не забота фронтмэна! Подакдакав, нашёл nodejs враппер plotly, под платный REST-API сервис https://plot.ly/, у которого по нынешним маркетинговым уловкам, конечно же был OpenSource вариант. Он то мне и подошёл.
Прицепив его к своему простенькому боту всё было здорово, пока не вспомнилось что бэкэнд бота лежит на Heroku, а там фишка с fs.createWriteStream() не срабатывает по понятным знатоку SaSS-причинам. Задача вроде плёвая: из результата POST-запроса достать res:
https.request(options, function handleResponse(res) {
if (res.statusCode !== 200) {
callback(null, res);
});
Вот только я не знаток днища, и сразу не понял что написанный в доки невнятный imageStream (по-сути res выше) на самом деле является IncomingMessage, и если не следовать доки с их:
var fileStream = fs.createWriteStream('1.png');
imageStream.pipe(fileStream);
Без моих знаний о стримах понимать что происходит было сложно, типичный кейс с Stack Overflow Driven Development не прокатывал, потому что Stackoverflow уже был засран этим нубским говнищем, и искать что-либо нормальное было нереально. Потому залип в нодовские доки, чтобы понять как работают эти их хитрые стримы.
const Writable = stream.Writable;
const ws = Writable();
const buffers = [];
ws._write = (chunk, enc, next) => {
buffers.push(chunk);
next();
};
imageStream.pipe(ws);
imageStream.on('end', () => {
const photoBuffer = Buffer.concat(buffers);
return bot.sendPhoto(chatId, photoBuffer, {
caption: 'Photo'
});
});
Скажу, они работают круто, но сложно. Всякие пайпы, события close, end и разделение на секции Readable и Writable. Итогом стал нижеследующий код, смысл которого в том, чтобы через Writable стрим запайпить тот самый imageStream, данные которого можно будет прочитать в ws._write через чанк. Главная трабла, что привычный механизм добавления чанков через строковую переменную здесь не работает, важно обязательно добавлять невредимый чанк в своем формает Int8Array. Я это сделал через buffers. Затем у imageStream появляется возможность уловить события end в котором складываем все собранные чанки в один буфер и посылаем куда следует. Mission Complete, Boss.
Модуль для работы с Plotly.js
Пример генерации графика с помощью библиотеки Plotly:
myplotly.js
const PLOTLY = {
LOGIN: 'YOUR_PLOTLY_LOGIN',
TOKEN: 'YOUR_PLOTLY_PASSWORD'
};
const plotly = require('plotly')(PLOTLY.LOGIN, PLOTLY.TOKEN);
const { Writable } = require('stream');
/**
* @returns {{x: Array, y: Array, type: string}}
*/
const createTrace = () => {
/**
* @constant {string}
*/
const BAR_TYPE = 'bar';
return {
x: [],
y: [],
type: BAR_TYPE,
};
};
/**
* @returns {{format: string, width: number, height: number}}
*/
const figureOptions = {
format: 'png',
width: 768,
height: 512,
};
/**
* @param {object} figure - figure
* @param {object} options - options object
* @returns {Promise<Buffer>}
*/
const getPlotlyImage = (figure, options = {}) => {
return new Promise((resolve, reject) => {
return plotly.getImage(figure, options, (error, imageStream) => {
if (error) {
return reject(error);
}
return resolve(imageStream);
});
});
};
/**
* @param {object} figure - object figure
* @param {object} options - options object
* @returns {Promise<Buffer>}
*/
const getImageBuffer = async (figure, options = {}) => {
const imageStream = await getPlotlyImage(figure, options);
return new Promise((resolve, reject) => {
const ws = Writable();
const buffers = [];
ws._write = (chunk, enc, next) => {
buffers.push(chunk);
next();
};
imageStream.pipe(ws);
imageStream.on('end', () => {
const photoBuffer = Buffer.concat(buffers);
return resolve(photoBuffer);
});
imageStream.on('error', (error) => {
return reject(error);
});
});
};
/**
* @param {string} plotId - plot id
* @returns {Promise}
*/
const deletePlot = (plotId) => {
return new Promise((resolve, reject) => {
return plotly.deletePlot(plotId, (error, plot) => {
if (error) {
return reject(error);
}
return resolve(plot);
});
});
};
/**
* @param {string|Date} date - date
* @returns {*}
*/
const getDateString = (date) => {
if (typeof date === 'string') {
return date;
} else {
return date.toLocaleDateString();
}
};
/**
* @param {Array} entryRows - rows
* @param {Array<string|Date>} rangeTimes - rangeTimes
* @returns {Promise<Error|Buffer>}
*/
const createPhotoBuffer = async (entryRows, rangeTimes) => {
const trace = createTrace();
rangeTimes.forEach((_date) => {
const traceX = getDateString(_date);
const traceY = entryRows.filter(({ date }) => {
return getDateString(date) === traceX;
}).length;
const xIndex = trace.x.findIndex((_x) => {
return _x === traceX;
});
if (xIndex < 0) {
trace.x.push(traceX);
trace.y.push(traceY);
} else {
++trace.y[xIndex];
}
});
const figure = { data: [trace] };
console.log('figure', figure)
const photoBuffer = await getImageBuffer(figure, figureOptions);
return photoBuffer;
};
module.exports = {
createPhotoBuffer,
deletePlot,
};
server.js
Демонстрация работы модуля, генерируем график и отправляем обратно пользователю бинарь с помощью своей функции graph внутри Firebase:
const functions = require('firebase-functions');
const dateFns = require('date-fns');
const { createPhotoBuffer } = require('./myplotly');
const getGraph = async (data) => {
const rows = data.map(d => {
// удаляем ненужные данные из rows
return {
date: new Date(d.created_at),
}
});
/**
* @constant
* @type {number}
*/
const MS_PER_DAY = 1000 * 60 * 60 * 24;
/**
* @param {Date} fromDate - from date
* @param {Date} untilDate - until date
* @returns {number}
*/
function getDifferenceDays (fromDate, untilDate) {
const differenceDays = Math.floor((untilDate - fromDate) / MS_PER_DAY);
if (differenceDays < 0) {
throw new Error('until less from');
}
return differenceDays;
}
/**
* Если есть "2016-05-01" и "2016-05-03" то автоматически создаются
* ["2016-05-01", "2016-05-02", "2016-05-03"]
*
* @description Эта функция заполняет датами пустоты во времени
* @param {string|Date} from - from date
* @param {string|Date} until - until date
* @returns {Array}
*/
function fillRangeTimes (from, until) {
const fromDate = new Date(from);
const untilDate = new Date(until);
if (!(dateFns.isValid(fromDate) && dateFns.isValid(untilDate))) {
throw new Error('Unknown param type');
}
const result = [];
const dayOffLength = getDifferenceDays(fromDate, untilDate);
for (let i = 0; i < dayOffLength; i++) {
const date = new Date(fromDate);
date.setDate(fromDate.getDate() + i);
fromDate.setHours(0);
fromDate.setMinutes(0);
fromDate.setMinutes(0);
result.push(date);
}
result.push(untilDate);
return result;
}
const firstDate = rows[0].date;
const latestDate = rows[rows.length - 1].date;
const rangeTimes = fillRangeTimes(firstDate, latestDate);
const photoBuffer = await createPhotoBuffer(rows, rangeTimes);
return photoBuffer;
};
exports.graph = functions.https.onRequest(async (request, response) => {
const image = await getGraph(request.body);
response.set('Content-Type', 'image/png');
response.end(image, 'binary');
});