Всем доброго времени суток! Это мой первый пост на Хабре в расчете на конструктивную критику и советы на дальнейшее развитие.
Предыстория
Я обучаюсь веб-разработке и во время обучения ко мне обратилась знакомая психолог и попросила:
Не мог ли бы ты написать программу для подсчета мандал?
И требования к проекту:
-
перевод слова в цифры по алфавиту;
-
генерация шестиугольной сетки радиусом в количество цифр переведенного слова с заданным размером грани ячейки и размером холста;
-
расстановка цифр в каждую ячейку по заранее определенному методу;
-
иметь возможность изменения цвета во всех ячейках с одинаковым номером;
-
иметь возможность подбора цвета с изображения;
-
вывод полученного изображения в файл для последующего распечатывания;
-
опционально иметь возможность вернуться к ранее созданной мандале для ее редактирования.
Для обучения мне это показалось классным кейсом. Я взялся за работу и принялся обдумывать какими средствами ее выполнить. Сразу на ум пришло: 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-разработке.