src_app_drawController.js
import {getIndexFromStringId} from '../math/index';
import {logger} from '../utils/logger';
import {replaceFlags} from '../utils/string';
import {getShadowColour} from '../utils/colour';
import {
getShapeDisplayName,
DrawGroupCommand,
DeleteGroupCommand
} from '../tools/drawCommands';
// doc imports
/* eslint-disable no-unused-vars */
import {Index} from '../math/index';
import {DrawLayer} from '../gui/drawLayer';
/* eslint-enable no-unused-vars */
/**
* Konva.
*
* Ref: {@link https://konvajs.org/}.
*
* @external Konva
*/
import Konva from 'konva';
/**
* Draw meta data.
*/
export class DrawMeta {
/**
* Draw quantification.
*
* @type {object}
*/
quantification;
/**
* Draw text expression. Can contain variables surrounded with '{}' that will
* be extracted from the quantification object.
*
* @type {string}
*/
textExpr;
}
/**
* Draw details.
*/
export class DrawDetails {
/**
* The draw ID.
*
* @type {number}
*/
id;
/**
* The draw position: an Index converted to string.
*
* @type {string}
*/
position;
/**
* The draw type.
*
* @type {string}
*/
type;
/**
* The draw color: for example 'green', '#00ff00' or 'rgb(0,255,0)'.
*
* @type {string}
*/
color;
/**
* The draw meta.
*
* @type {DrawMeta}
*/
meta;
}
/**
* Is an input node's name 'shape'.
*
* @param {Konva.Node} node A Konva node.
* @returns {boolean} True if the node's name is 'shape'.
*/
export function isNodeNameShape(node) {
return node.name() === 'shape';
}
/**
* Is a node an extra shape associated with a main one.
*
* @param {Konva.Node} node A Konva node.
* @returns {boolean} True if the node's name starts with 'shape-'.
*/
export function isNodeNameShapeExtra(node) {
return node.name().startsWith('shape-');
}
/**
* Is an input node's name 'label'.
*
* @param {Konva.Node} node A Konva node.
* @returns {boolean} True if the node's name is 'label'.
*/
export function isNodeNameLabel(node) {
return node.name() === 'label';
}
/**
* Is an input node a position node.
*
* @param {Konva.Node} node A Konva node.
* @returns {boolean} True if the node's name is 'position-group'.
*/
export function isPositionNode(node) {
return node.name() === 'position-group';
}
/**
* @callback testFn
* @param {Konva.Node} node The node.
* @returns {boolean} True if the node passes the test.
*/
/**
* Get a lambda to check a node's id.
*
* @param {string} id The id to check.
* @returns {testFn} A function to check a node's id.
*/
export function isNodeWithId(id) {
return function (node) {
return node.id() === id;
};
}
/**
* Is the input node a node that has the 'stroke' method.
*
* @param {Konva.Node} node A Konva node.
* @returns {boolean} True if the node's name is 'anchor' and 'label'.
*/
export function canNodeChangeColour(node) {
return node.name() !== 'anchor' && node.name() !== 'label';
}
/**
* Debug function to output the layer hierarchy as text.
*
* @param {object} layer The Konva layer.
* @param {string} prefix A display prefix (used in recursion).
* @returns {string} A text representation of the hierarchy.
*/
export function getHierarchyLog(layer, prefix) {
if (typeof prefix === 'undefined') {
prefix = '';
}
const kids = layer.getChildren();
let log = prefix + '|__ ' + layer.name() + ': ' + layer.id() + '\n';
for (let i = 0; i < kids.length; ++i) {
log += getHierarchyLog(kids[i], prefix + ' ');
}
return log;
}
/**
* Draw controller.
*/
export class DrawController {
/**
* The draw layer.
*
* @type {DrawLayer}
*/
#drawLayer;
/**
* The Konva layer.
*
* @type {Konva.Layer}
*/
#konvaLayer;
/**
* Current position group id.
*
* @type {string}
*/
#currentPosGroupId = null;
/**
* @param {DrawLayer} drawLayer The draw layer.
*/
constructor(drawLayer) {
this.#drawLayer = drawLayer;
this.#konvaLayer = drawLayer.getKonvaLayer();
}
/**
* Get the current position group.
*
* @returns {Konva.Group|undefined} The Konva.Group.
*/
getCurrentPosGroup() {
// get position groups
const posGroups = this.#konvaLayer.getChildren((node) => {
return node.id() === this.#currentPosGroupId;
});
// if one group, use it
// if no group, create one
let posGroup;
if (posGroups.length === 1) {
if (posGroups[0] instanceof Konva.Group) {
posGroup = posGroups[0];
}
} else if (posGroups.length === 0) {
posGroup = new Konva.Group();
posGroup.name('position-group');
posGroup.id(this.#currentPosGroupId);
posGroup.visible(true); // dont inherit
// add new group to layer
this.#konvaLayer.add(posGroup);
} else {
logger.warn('Unexpected number of draw position groups.');
}
// return
return posGroup;
}
/**
* Reset: clear the layers array.
*/
reset() {
this.#konvaLayer = null;
}
/**
* Get a Konva group using its id.
*
* @param {string} id The group id.
* @returns {object|undefined} The Konva group.
*/
getGroup(id) {
const group = this.#konvaLayer.findOne('#' + id);
if (typeof group === 'undefined') {
logger.warn('Cannot find node with id: ' + id
);
}
return group;
}
/**
* Activate the current draw layer.
*
* @param {Index} index The current position.
* @param {number} scrollIndex The scroll index.
*/
activateDrawLayer(index, scrollIndex) {
// TODO: add layer info
// get and store the position group id
const dims = [scrollIndex];
for (let j = 3; j < index.length(); ++j) {
dims.push(j);
}
this.#currentPosGroupId = index.toStringId(dims);
// get all position groups
const posGroups = this.#konvaLayer.getChildren(isPositionNode);
// reset or set the visible property
let visible;
for (let i = 0, leni = posGroups.length; i < leni; ++i) {
visible = false;
if (posGroups[i].id() === this.#currentPosGroupId) {
visible = true;
}
// group members inherit the visible property
posGroups[i].visible(visible);
}
// show current draw layer
this.#konvaLayer.draw();
}
/**
* Get a list of drawing display details.
*
* @returns {DrawDetails[]} A list of draw details.
*/
getDrawDisplayDetails() {
const list = [];
const groups = this.#konvaLayer.getChildren();
for (let j = 0, lenj = groups.length; j < lenj; ++j) {
const position = getIndexFromStringId(groups[j].id());
// @ts-ignore
const collec = groups[j].getChildren();
for (let i = 0, leni = collec.length; i < leni; ++i) {
const shape = collec[i].getChildren(isNodeNameShape)[0];
const label = collec[i].getChildren(isNodeNameLabel)[0];
const text = label.getChildren()[0];
let type = shape.className;
if (type === 'Line') {
const shapeExtrakids = collec[i].getChildren(
isNodeNameShapeExtra);
if (shape.closed()) {
type = 'Roi';
} else if (shapeExtrakids.length !== 0) {
const extraName0 = shapeExtrakids[0].name();
if (extraName0.indexOf('triangle') !== -1) {
type = 'Arrow';
} else if (extraName0.indexOf('arc') !== -1) {
type = 'Protractor';
} else {
type = 'Ruler';
}
}
}
if (type === 'Rect') {
type = 'Rectangle';
}
list.push({
id: collec[i].id(),
position: position.toString(),
type: type,
color: shape.stroke(),
meta: text.meta
});
}
}
return list;
}
/**
* Get a list of drawing store details. Used in state.
*
* @returns {object} A list of draw details including id, text, quant...
* TODO Unify with getDrawDisplayDetails?
*/
getDrawStoreDetails() {
const drawingsDetails = {};
// get all position groups
const posGroups = this.#konvaLayer.getChildren(isPositionNode);
let posKids;
let group;
for (let i = 0, leni = posGroups.length; i < leni; ++i) {
// @ts-ignore
posKids = posGroups[i].getChildren();
for (let j = 0, lenj = posKids.length; j < lenj; ++j) {
group = posKids[j];
// remove anchors
const anchors = group.find('.anchor');
for (let a = 0; a < anchors.length; ++a) {
anchors[a].remove();
}
// get text
const texts = group.find('.text');
if (texts.length !== 1) {
logger.warn('There should not be more than one text per shape.');
}
// get meta (non konva vars)
drawingsDetails[group.id()] = {
meta: texts[0].meta
};
}
}
return drawingsDetails;
}
/**
* Set the drawings on the current stage.
*
* @param {Array} drawings An array of drawings.
* @param {DrawDetails[]} drawingsDetails An array of drawings details.
* @param {object} cmdCallback The DrawCommand callback.
* @param {object} exeCallback The callback to call once the
* DrawCommand has been executed.
*/
setDrawings(
drawings, drawingsDetails, cmdCallback, exeCallback) {
// regular Konva deserialize
const stateLayer = Konva.Node.create(drawings);
// get all position groups
const statePosGroups = stateLayer.getChildren(isPositionNode);
for (let i = 0, leni = statePosGroups.length; i < leni; ++i) {
const statePosGroup = statePosGroups[i];
// Get or create position-group if it does not exist and
// append it to konvaLayer
let posGroup = this.#konvaLayer.getChildren(
isNodeWithId(statePosGroup.id()))[0];
if (typeof posGroup === 'undefined') {
posGroup = new Konva.Group({
id: statePosGroup.id(),
name: 'position-group',
visible: false
});
this.#konvaLayer.add(posGroup);
}
const statePosKids = statePosGroup.getChildren();
for (let j = 0, lenj = statePosKids.length; j < lenj; ++j) {
// shape group (use first one since it will be removed from
// the group when we change it)
const stateGroup = statePosKids[0];
// add group to posGroup (switches its parent)
// @ts-ignore
posGroup.add(stateGroup);
// shape
const shape = stateGroup.getChildren(isNodeNameShape)[0];
// create the draw command
const cmd = new DrawGroupCommand(
stateGroup,
shape.className,
this.#drawLayer
);
// draw command callbacks
cmd.onExecute = cmdCallback;
cmd.onUndo = cmdCallback;
// details
if (drawingsDetails) {
const details = drawingsDetails[stateGroup.id()];
const label = stateGroup.getChildren(isNodeNameLabel)[0];
const text = label.getText();
// store details
text.meta = details.meta;
// reset text (it was not encoded)
text.setText(replaceFlags(
text.meta.textExpr, text.meta.quantification
));
}
// execute
cmd.execute();
exeCallback(cmd);
}
}
}
/**
* Update a drawing from its details.
*
* @param {DrawDetails} drawDetails Details of the drawing to update.
*/
updateDraw(drawDetails) {
// get the group
const group = this.#konvaLayer.findOne('#' + drawDetails.id);
if (typeof group === 'undefined') {
logger.warn(
'[updateDraw] Cannot find group with id: ' + drawDetails.id
);
return;
}
// shape
// @ts-ignore
const shapes = group.getChildren(isNodeNameShape);
for (let i = 0; i < shapes.length; ++i) {
shapes[i].stroke(drawDetails.color);
}
// shape extra
// @ts-ignore
const shapesExtra = group.getChildren(isNodeNameShapeExtra);
for (let j = 0; j < shapesExtra.length; ++j) {
if (typeof shapesExtra[j].stroke() !== 'undefined') {
shapesExtra[j].stroke(drawDetails.color);
} else if (typeof shapesExtra[j].fill() !== 'undefined') {
// for example text
shapesExtra[j].fill(drawDetails.color);
}
}
// label
// @ts-ignore
const label = group.getChildren(isNodeNameLabel)[0];
const shadowColor = getShadowColour(drawDetails.color);
const kids = label.getChildren();
for (let k = 0; k < kids.length; ++k) {
const kid = kids[k];
kid.fill(drawDetails.color);
if (kids[k].className === 'Text') {
const text = kids[k];
text.shadowColor(shadowColor);
if (typeof drawDetails.meta !== 'undefined') {
text.meta = drawDetails.meta;
text.setText(replaceFlags(
text.meta.textExpr, text.meta.quantification
));
label.setVisible(text.meta.textExpr.length !== 0);
}
}
}
// udpate current layer
this.#konvaLayer.draw();
}
/**
* Delete a Draw from the stage.
*
* @param {Konva.Group} group The group to delete.
* @param {object} cmdCallback The DeleteCommand callback.
* @param {object} exeCallback The callback to call once the
* DeleteCommand has been executed.
*/
deleteDrawGroup(group, cmdCallback, exeCallback) {
const shape = group.getChildren(isNodeNameShape)[0];
if (!(shape instanceof Konva.Shape)) {
return;
}
const shapeDisplayName = getShapeDisplayName(shape);
const delcmd = new DeleteGroupCommand(
group,
shapeDisplayName,
this.#drawLayer
);
delcmd.onExecute = cmdCallback;
delcmd.onUndo = cmdCallback;
delcmd.execute();
// callback
if (typeof exeCallback !== 'undefined') {
exeCallback(delcmd);
}
}
/**
* Delete a Draw from the stage.
*
* @param {string} id The id of the group to delete.
* @param {Function} cmdCallback The DeleteCommand callback.
* @param {Function} exeCallback The callback to call once the
* DeleteCommand has been executed.
* @returns {boolean} False if the group cannot be found.
*/
deleteDraw(id, cmdCallback, exeCallback) {
// get the group
const group = this.getGroup(id);
if (typeof group === 'undefined') {
return false;
}
// delete
this.deleteDrawGroup(group, cmdCallback, exeCallback);
return true;
}
/**
* Delete all Draws from the stage.
*
* @param {Function} cmdCallback The DeleteCommand callback.
* @param {Function} exeCallback The callback to call once the
* DeleteCommand has been executed.
*/
deleteDraws(cmdCallback, exeCallback) {
const posGroups = this.#konvaLayer.getChildren();
for (const posGroup of posGroups) {
if (posGroup instanceof Konva.Group) {
const shapeGroups = posGroup.getChildren();
while (shapeGroups.length) {
if (shapeGroups[0] instanceof Konva.Group) {
this.deleteDrawGroup(shapeGroups[0], cmdCallback, exeCallback);
}
}
} else {
logger.warn('Found non group in layer while deleting');
}
}
}
/**
* Get the total number of draws
* (at all positions).
*
* @returns {number} The total number of draws.
*/
getNumberOfDraws() {
const posGroups = this.#konvaLayer.getChildren();
let count = 0;
for (const posGroup of posGroups) {
if (posGroup instanceof Konva.Group) {
count += posGroup.getChildren().length;
}
}
return count;
}
} // class DrawController