Всем доброго времени суток! Это мой первый пост на Хабре в расчете на конструктивную критику и советы на дальнейшее развитие.
Предыстория
Я обучаюсь веб-разработке и во время обучения ко мне обратилась знакомая психолог и попросила:
Не мог ли бы ты написать программу для подсчета мандал?
И требования к проекту:
перевод слова в цифры по алфавиту;
генерация шестиугольной сетки радиусом в количество цифр переведенного слова с заданным размером грани ячейки и размером холста;
расстановка цифр в каждую ячейку по заранее определенному методу;
иметь возможность изменения цвета во всех ячейках с одинаковым номером;
иметь возможность подбора цвета с изображения;
вывод полученного изображения в файл для последующего распечатывания;
опционально иметь возможность вернуться к ранее созданной мандале для ее редактирования.
Для обучения мне это показалось классным кейсом. Я взялся за работу и принялся обдумывать какими средствами ее выполнить. Сразу на ум пришло: PyQt 5, книгу по которому мне заботливо подарили год назад, и веб-приложение. PyQt 5 я не знаю, да и не моя сфера обучения, а потому буду писать веб приложение. Для бекенда был выбран Flask, т.к. один из кейсов во время обучения был интеграция приложения Salesforce с Telegram ботом (Да. Я обучаюсь разработке на Salesforce) и Flask показался весьма простым и гибким фреймворком. Фронтенд было решено делать на Bootstrap и JQuery.
В связи с тем, что у моего знакомого психолога нет навыков работы с командной строкой, а так же работы с Photoshop или CorelDraw, было решено написать фронтенд, бекенд, скрипт Powershell для запуска виртуального окружения Python и открытия браузера по умолчанию на заданный адрес.
Ниже присланное мне изображение по расчету мандалы.
Генерация изображения
Как произвести генерацию шестиугольной сетки? Проведя поиск я наткнулся на статью. Пройдя к источнику, нашел библиотеки для различных языков программирования.
Это первый подобный опыт работы с изображением, используя JS, первая реализация была выполнена на Canvas, сетку выводила библиотека Honeycomb.
Длинный кусок кода
function buildMandalaVer12(countHex, strToHex) {
var element = document.getElementById("stage");
element.height = window.innerHeight;
element.width = window.innerWidth;
var stage = new createjs.Stage("stage");
stage.x = window.innerWidth / 2;
stage.y = window.innerHeight / 2;
var grid = new Grid();
grid.tileSize = 30;
grid.tileSpacing = 0;
grid.pointyTiles = false;
var stageTransformer = new StageTransformer().initialize({
element: element,
stage: stage
});
stageTransformer.addEventListeners();
var coordinates = grid.hexagon(0, 0, countHex, true);
let breakIter = false;
let stepRay = 0;
let stepBreak = 0;
let interimStep = 0;
let u = 1;
for (var i = 0; i < coordinates.length; i++) {
var q = coordinates[i].q,
r = coordinates[i].r,
center = grid.getCenterXY(q, r),
hexagon = new createjs.Shape();
if (u == stepBreak){
breakIter = false;
u = 0;
}
if (!breakIter) {
let str = strToHex[stepRay];
var text = new createjs.Text(str, "10px Arial", "#ff7700");
text.textBaseline = "alphabetic";
text.set({
textAlign: "center",
textBaseline: "middle",
x: center.x,
y: center.y,
rotation: -90
});
if (stepRay > 0) {
interimStep += 1;
}
}
if (interimStep == 6) {
breakIter = true;
interimStep = 1;
stepBreak += 1;
} else {
u += 1;
}
if (i + 1 == 1) {
stepRay = 1
}
hexagon.id = i;
hexagon.id2 = i;
// раскаска диагональных линий
if ((q == 0 && r == 0) || q == 0 || r == 0 || ((q * -1) == r)) {
hexagon.graphics
.beginFill("rgba(150,0,0,1)")
// .beginFill("rgba(150,150,150,1)")
.beginStroke("rgba(250,250,250,1)")
.drawPolyStar(0, 0, grid.tileSize, 6, 0, 0);
} else {
hexagon.graphics
.beginFill("rgba(150,150,150,1)")
.beginStroke("rgba(250,250,250,1)")
.drawPolyStar(0, 0, grid.tileSize, 6, 0, 0);
}
hexagon.q = q;
hexagon.r = r;
hexagon.x = center.x;
hexagon.y = center.y;
hexagon.addEventListener("click", function (event) {
if (!stageTransformer.mouse.moved) {
console.log(event.target.id)
event.target.graphics
.clear()
.beginFill("rgba(150,0,0,1)")
.beginStroke("rgba(50,0,0,1)")
.drawPolyStar(0, 0, grid.tileSize, 6, 0, 0);
}
});
stage.addChild(hexagon);
stage.addChild(text);
}
stage.set({
rotation: 90
});
var tick = function (event) {
stage.update();
};
tick();
createjs.Ticker.setFPS(60);
createjs.Ticker.addEventListener("tick", tick);
return true;
}
Размеры в SVG считаются в пикселях, а значит миллиметры необходимо перевести. На developer.mozilla.org сказано:
Ок. Значит параметры вводимые в форме настроек генерации переводим и устанавливаем для параметров сетки.
Код генерации сетки
let preload = document.getElementById('preloader');
// перевод мм в пиксели
let dWith = modelMandala.source.pageSize.width * 3.543307;
let dHeight = modelMandala.source.pageSize.height * 3.543307;
let dRangeMm = modelMandala.source.rangeMm * 3.543307;
// генерация сетки и пересчет координат
let options = new BHex.Drawing.Options(dRangeMm, BHex.Drawing.Static.Orientation.PointyTop, new BHex.Drawing.Point(dWith, dHeight));
let gridBHex = new BHex.Grid(modelMandala.source.countWord);
let gridForPaint = new BHex.Drawing.Drawing(gridBHex, options);
Теперь нужно создать само изображение на основе координат. Для этого SVG.js передаем координаты для отрисовки Polygon и элемента Text для большей наглядности.
// создание нового объекта svg и добавление его в <object> на странице
modelMandala.source.drawThisFigure = SVG().addTo(preload).size(dWith, dHeight).id("svgImg2");
document.getElementById("svgImg2").setAttribute('style', 'shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd');
let fontSize = modelMandala.source.rangeFontSize;
// отрисовка шестиугольников и элементов Text
for (let i = 0; i < gridForPaint.grid.hexes.length; i++) {
modelMandala.source.drawThisFigure.polygon(gridForPaint.grid.hexes[i].points.map(({x, y}) => `${x},${y}`))
.fill('none')
.stroke({width: 1, color: '#000000'})
.css({cursor: 'pointer'})
.addClass('polygon')
.addClass('999')
.addClass(`${gridForPaint.grid.hexes[i].x},${gridForPaint.grid.hexes[i].y}`)
.element('title').words(`${gridForPaint.grid.hexes[i].x},${gridForPaint.grid.hexes[i].y}`)
modelMandala.source.drawThisFigure
.text(`${gridForPaint.grid.hexes[i].x},${gridForPaint.grid.hexes[i].y}`)
.font({
size: fontSize,
anchor: 'middle',
leading: 1.4,
fill: modelMandala.source.colorWord
})
.addClass(`${gridForPaint.grid.hexes[i].x},${gridForPaint.grid.hexes[i].y}`)
.translate(gridForPaint.grid.hexes[i].center.x, gridForPaint.grid.hexes[i].center.y + 3);
}
И добавление события по клику для вызова модального окна с палитрой.
for (let i = 0; i < modelMandala.source.drawThisFigure.node.children.length; i++) {
if (modelMandala.source.drawThisFigure.node.children[i].tagName === "polygon") {
let str = modelMandala.source.drawThisFigure.node.children[i].classList[2];
dataPolygonMap.set(str, i);
modelMandala.source.drawThisFigure.node.children[i].onclick = function () {
SetColorPolygonFunc();
polygonObj = modelMandala.source.drawThisFigure.node.children[i];
}
}
if (modelMandala.source.drawThisFigure.node.children[i].tagName === "text") {
let str = modelMandala.source.drawThisFigure.node.children[i].classList[0];
dataTextMap.set(str, i);
}
}
Но изображение отрисовывается не полностью,а лишь частью.
Посмотрев координаты стало понятно, что рассчет идет от центра (координаты 0,0), а в документации SVG говорится, что есть несколько областей для просмотра. Пользовательская область задается параметром Viewbox = "0 0 0 0". Ок. Делим размер всего холста на -2.
modelMandala.source.drawThisFigure.viewbox(dWith / -2 + ' ' + dHeight / -2 + ' ' + dWith + ' ' + dHeight);
Далее необходимо провести рассчет значений во внутренних многоугольниках. Условно всю мандалу можно поделить на шесть секторов. Можно взять координаты сетки, их я сохранял в виде класса для каждого polygon, и на их основе произвести рассчет как на бумаге: берем два базовых шестиугольника, складываем цифры в них и записываем полученное значение в нужный нам шестиугольник, и так до конца. Для этого при добавлении event в каждый шестиугольник я добавлял его координаты в Map, сам SVG сохранил в глобальную переменную, а при перессчете координат создавал многоуровневый массив с координатами для каждого сектора.
// объект для сохранения всех данных в процессе работы
let modelMandala = {
rayA: {
rayCoord: [],
sector: []
},
rayB: {
rayCoord: [],
sector: []
},
rayC: {
rayCoord: [],
sector: []
},
...
// вызов функции для просчета координат и собственно значений в шестиугольниках
if (modelMandala.source.mandalaVersion === 1 || modelMandala.source.mandalaVersion === 2 || modelMandala.source.mandalaVersion === 3 || modelMandala.source.mandalaVersion === 4) {
axialDataSetFunc();
}
if (modelMandala.source.mandalaVersion === 5 || modelMandala.source.mandalaVersion === 6 || modelMandala.source.mandalaVersion === 7 || modelMandala.source.mandalaVersion === 8) {
borderDataSetFunc();
}
// заполнение мандалы "по грани" координатами
function getArrOnBorderAndSector() {
let resArr = [];
let interimArr = [];
let step = modelMandala.source.countWord;
// ray A
let a = modelMandala.source.countWord, b = 0;
let a2 = a, b2 = b, interimStep = step;
for (let i = 1; i <= step; i++) {
for (let y = 1; y <= interimStep; y++) {
if (i === 1) {
y === 1 ? modelMandala.rayA.rayCoord.push([a2, b2]) : modelMandala.rayA.rayCoord.push([a2, --b2])
} else {
y === 1 ? interimArr.push([a2, b2]) : interimArr.push([a2, --b2])
}
}
a -= 1; b = 0; a2 = a; b2 = b;
interimStep -= 1;
if (i !== 1) {
resArr.push(interimArr);
interimArr = [];
}
}
modelMandala.rayA.sector.push(resArr);
resArr = [];
// ray B
...
// установка значений в класс для автовыбора при расскращивании.
function axialDataSet() {
$('.navbar').width(0)
getArrOnRayAndSector();
// установка значений по осям
let numb = 1;
for (let key in modelMandala) {
numb = 1;
if (key === "source") break;
for (let i = 0; i < modelMandala[key].rayCoord.length; i++) {
let obj = getValOnCoordinate(modelMandala.source.drawThisFigure, modelMandala[key].rayCoord[i][0], modelMandala[key].rayCoord[i][1], false);
obj.classList.replace('999', String(modelMandala.source.wordInInt[numb]));
obj.firstChild.innerHTML = String(modelMandala.source.wordInInt[numb]);
obj.attributes.fill.value = '#f62b58';
let objText = getValOnCoordinate(modelMandala.source.drawThisFigure, modelMandala[key].rayCoord[i][0], modelMandala[key].rayCoord[i][1], true);
objText.firstChild.innerHTML = String(modelMandala.source.wordInInt[numb]);
numb++;
}
}
// установка значений по полям
for (let key in modelMandala) {
let countStep = 1;
if (key === "source") break;
for (let i = 0; i < modelMandala[key].sector[0].length; i++) {
for (let u = 0; u < modelMandala[key].sector[0][i].length; u++) {
let objForChange = getValOnCoordinate(modelMandala.source.drawThisFigure, modelMandala[key].sector[0][i][u][0], modelMandala[key].sector[0][i][u][1], false);
let objParent1, objParent2;
if (key === "rayA") {
objParent1 = getValOnCoordinate(modelMandala.source.drawThisFigure, modelMandala[key].sector[0][i][u][0] - 1, modelMandala[key].sector[0][i][u][1], false);
objParent2 = getValOnCoordinate(modelMandala.source.drawThisFigure, modelMandala[key].sector[0][i][u][0] - 1, modelMandala[key].sector[0][i][u][1] + 1, false);
}
...
let res = Number(objParent1.classList[1]) + Number(objParent2.classList[1]);
if (res >= 10) {
res = String(res);
res = Number(res[0]) + Number(res[1]);
}
objForChange.classList.replace('999', String(res));
objForChange.firstChild.innerHTML = String(res);
objForChange.attributes.fill.value = '#f62bdb';
let objText = getValOnCoordinate(modelMandala.source.drawThisFigure, modelMandala[key].sector[0][i][u][0], modelMandala[key].sector[0][i][u][1], true);
objText.firstChild.innerHTML = String(res);
}
}
}
// установка центрального значения
let obj = getValOnCoordinate(modelMandala.source.drawThisFigure, 0, 0, false);
obj.classList.replace('999', String(modelMandala.source.wordInInt[0]));
obj.firstChild.innerHTML = String(modelMandala.source.wordInInt[0]);
obj.attributes.fill.value = '#f62b58';
let objText = getValOnCoordinate(modelMandala.source.drawThisFigure, 0, 0, true);
objText.firstChild.innerHTML = String(modelMandala.source.wordInInt[0]);
objText.setAttribute("font-weight", "900");
$('#getImageSchema').popover('enable');
$('#getImageSchema').removeClass("invisible");
$('#getImageColor').removeClass("invisible");
$('.navbar').width(document.documentElement.scrollWidth)
setProgress(true);
}
В приведенном выше кода описано: перессчет координат по секторам и создание многомерных массивов, расставления значений, начиная с осей мандалы, и перессчет значений на основе порядка массива координат в каждую ячейку для каждого сектора. Получение значений двух базовых шестиугольников производится в функции getValOnCoordinate
// получение координаты из ListStyle
function getValOnCoordinate(stage, o1, o2, text) {
let strForSearch = String(o1) + "," + String(o2);
if (text) {
let o = dataTextMap.get(strForSearch);
return stage.node.children[o];
} else {
let o = dataPolygonMap.get(strForSearch);
return stage.node.children[o];
}
}
Результат работы:
Интрефейс для работы
Было решено дать полную свободу действий в настройке изображения на свой вкус и цвет и создать два меню: для сохранения в базу, для генерации и сохранения в файл избражения или схемы. Предполагается, что готовое изображение будет распечатываться в виде схемы и некто будет его раскрашивать по номерам. Прошу не пинать, т.к. проект я рассматривал только с точки зрения опыта, а не подтекста использования на людях. Итого: меню для генерации и сохрания в файл и меню для сохранения в базу.
Так же один из элементов интерфейса является палитра, выполненная в Modal. Имеется две палитры на выбор: обыная и по требованию выбор цвета с рисунка.
И уведомляшки о сохранении, загрузке и удалении готового рисунка.
Весь HTML код написан в шаблонах Flask и отражать его в статье нет смысла, т.к. это обычная верстка с применением Bootstrap.
Сохранение изображения в файл
Изначально была задумка отдавать пользователю SVG файл, но из-за отсутствия навыков работы в Corel самым простым решением было выводить pdf с заданным размером страницы и в последующем его печатать.
Сам процесс перевода SVG в pdf на фронте (ссылка) оказался чрезвычайно долгим, т.к. происходил полный перебор SVG со страницы, генерация pdf и последущее добавление polygon в файл. Проще всего оказалось отдать файл на back-end, конвертировать его там и отдать pdf пользователю. Отдача SVG происходит Ajax в виде строки, которая записвается в файл, а дальше вступает в действие Cariosvg. После готовый pdf отдается на фронтенд ответом на Ajax в виде бинарной строки.
Код сохранения svg, его перевода в pdf и отдача файла пользователю
/*
* сохранение изображения
* */
$("#getImageSchema").on('click', function () {
setProgress(false);
for (let i = 0; i < modelMandala.source.drawThisFigure.node.children.length; i++) {
if (modelMandala.source.drawThisFigure.node.children[i].tagName === "polygon") {
modelMandala.source.drawThisFigure.node.children[i].attributes.fill.value = '#ffffff';
}
}
getPdf();
});
$("#getImageColor").on('click', function () {
setProgress(false);
getPdf();
});
function getPdf() {
let dWith = modelMandala.source.pageSize.width * 3.543307;
let dHeight = modelMandala.source.pageSize.height * 3.543307;
let svh = new XMLSerializer().serializeToString(document.getElementById('svgImg2'))
$.ajax({
type: "POST",
url: "/upload",
data: {data: svh, pWidth: dWith, pHeight: dHeight},
dataType: 'binary',
xhrFields: {
'responseType': 'blob'
},
}).done(function (data, status, xhr) {
let link = document.createElement('a'), filename = modelMandala.source.word + '.pdf';
link.href = URL.createObjectURL(data);
link.download = filename;
link.click();
setProgress(true);
});
}
@app.route('/upload', methods=['POST'])
def upload():
if os.path.exists(uploads_dir + '\\test.svg'):
os.remove(uploads_dir + '\\test.svg')
if os.path.exists(uploads_dir + '\\test.pdf'):
os.remove(uploads_dir + '\\test.pdf')
f = open(uploads_dir + '\\test.svg', 'w')
f.write(request.form['data'])
f.close()
x = threading.Thread(target=cairosvg.svg2pdf(url=uploads_dir + '\\test.svg', write_to=uploads_dir + '\\test.pdf'),
args=(1,))
x.start()
x.join()
return send_file(filename_or_fp=uploads_dir + '\\test.pdf', mimetype='application/pdf; version="1.5"',
as_attachment=True)
Сохранение и восстановление данных из базы также просто. На python передается json со всеми данными объекта modelMandala и сериализованный SVG. Используя sqlalchemy сохраняю эти данные в файл SQLite.
Далее должо было быть написание скрипта powershell для запуска Fask и в целом на этом можно было бы заканчивать, но...
Послесловие
Не интересуясь возможностями JS и случайно наткнувшись на пост я открыл для себя Electron JS и подумал: а что если все приложение упаковать pyinstaller и при запуске Electron приложения производит запуск упакованного файла python, а при закрытии окна завершать его работу? Для конечного пользователя это устраняет необходимость установки python и наличия открытого окна консоли powershell. В итоге так и было сделано. Ниже файл render.js для electron и package.json для сборки приложения.
render.js
"use strict";
const {app, BrowserWindow, session, Menu, MenuItem} = require("electron");
const path = require("path");
let mainWindow = null;
let subpy = null;
const PY_DIST_FOLDER = "dist-python";
const PY_SRC_FOLDER = "web_app";
const PY_MODULE = "wsgi.py";
const isRunningInBundle = () => {
return require("fs").existsSync(path.join(__dirname, PY_DIST_FOLDER));
};
const getPythonScriptPath = () => {
if (!isRunningInBundle()) {
return path.join(__dirname, PY_SRC_FOLDER, PY_MODULE);
}
if (process.platform === "win32") {
return path.join(
__dirname,
PY_DIST_FOLDER,
PY_MODULE.slice(0, -3) + ".exe"
);
}
return path.join(__dirname, PY_DIST_FOLDER, PY_MODULE);
};
const startPythonSubprocess = () => {
let script = getPythonScriptPath();
if (isRunningInBundle()) {
subpy = require("child_process").execFile(script, []);
} else {
subpy = require("child_process").spawn("python", [script]);
}
};
const killPythonSubprocesses = main_pid => {
const python_script_name = path.basename(getPythonScriptPath());
let cleanup_completed = false;
const psTree = require("ps-tree");
psTree(main_pid, function (err, children) {
let python_pids = children
.filter(function (el) {
return el.COMMAND == python_script_name;
})
.map(function (p) {
return p.PID;
});
python_pids.forEach(function (pid) {
process.kill(pid);
});
subpy = null;
cleanup_completed = true;
});
return new Promise(function (resolve, reject) {
(function waitForSubProcessCleanup() {
if (cleanup_completed) return resolve();
setTimeout(waitForSubProcessCleanup, 30);
})();
});
};
const createMainWindow = () => {
mainWindow = new BrowserWindow({
width: 1366,
height: 768,
icon: __dirname + "/icon.ico",
fullscreen: true,
frame: true,
resizeable: true
});
mainWindow.loadURL("http://localhost:5000/");
mainWindow.on("closed", function () {
mainWindow = null;
});
};
app.on("ready", function () {
startPythonSubprocess();
createMainWindow();
setTimeout(()=>{
mainWindow.loadURL("http://localhost:5000/");
}, 5);
});
const template = [
{
role: 'Help',
submenu: [
{
role: 'reload'
},
{
role: 'close'
}
]
}
]
const menu = Menu.buildFromTemplate(template)
app.on("browser-window-created", function (e, window) {
window.setMenu(menu);
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
let main_process_pid = process.pid;
killPythonSubprocesses(main_process_pid).then(() => {
app.quit();
});
}
});
app.on("activate", () => {
if (subpy == null) {
startPythonSubprocess();
}
if (mainWindow === null) {
createMainWindow();
}
});
app.on("quit", function () {
session.defaultSession.clearCache();
});
package.json для сборки приложения
{
"name": "MandalaApp",
"version": "1.2.0",
"description": "",
"main": "renderer.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "electron .",
"package": "npm run -s package-python && npm run -s package-electron && npm run -s package-cleanup",
"package-python": "pyinstaller -w --onefile --add-binary web_app/venv/Lib/site-packages/cairosvg;cairosvg --add-binary web_app/venv/Lib/site-packages/cairocffi;cairocffi --add-binary web_app/venv/Lib/site-packages/PIL;pillow --add-binary web_app/venv/Lib/site-packages/defusedxml;defusedxml --add-binary web_app/venv/Lib/site-packages/tinycss2;tinycss2 --add-binary web_app/venv/Lib/site-packages/cssselect2;cssselect2 --add-binary web_app/venv/Lib/site-packages/cffi;cffi --add-binary web_app/venv/Lib/site-packages/pycparser;pycparser --add-binary web_app/venv/Lib/site-packages/webencodings;webencodings --add-data web_app/templates;templates --add-data web_app/static;static --add-data web_app/instance;instance --add-data web_app/migrations;migrations --add-data web_app/instance/app.db;instance web_app/wsgi.py web_app/app.py web_app/config.py web_app/identifier.sqlite web_app/models.py web_app/routes.py --distpath dist-python",
"package-electron": "electron-builder"
},
"build": {
"appId": "com.MandalaApp.klim-app",
"productName": "MandalaApp",
"asar": false,
"asarUnpack": [
"**/*.node"
],
"mac": {
"category": "public.app-category.utilities"
},
"files": [
"renderer.js",
"icon.ico",
"node_modules/**/*"
],
"extraResources": [
{
"from": "dist-python/",
"to": "app/dist-python",
"filter": [
"**/*"
]
}
]
},
"author": "Ilia Klimchik https://github.com/klimchak",
"license": "MIT",
"dependencies": {
"ps-tree": "^1.2.0"
},
"devDependencies": {
"electron": "^9.2.0",
"electron-builder": "^22.8.0"
}
}
В итоге получилось десктопное приложение по всем заранее оговоренным требованиям.
Более подробно посмотреть код можно на GitHub.
P.s. Прошу сильно не забрасывать камнями. Буду рад любой конструкивной критике, code review, советам по иной реализации, а так же по обучению и направлению развития в web-разработке.