/**
* File : boxes/BoxConnector.js
* Created : 26/05/2015
* By : Francesc Busquets <francesc@gmail.com>
*
* JClic.js
* An HTML5 player of JClic activities
* https://projectestac.github.io/jclic.js
*
* @source https://github.com/projectestac/jclic.js
*
* @license EUPL-1.2
* @licstart
* (c) 2000-2020 Catalan Educational Telematic Network (XTEC)
*
* Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
* the European Commission- subsequent versions of the EUPL (the "Licence");
* You may not use this work except in compliance with the Licence.
*
* You may obtain a copy of the Licence at:
* https://joinup.ec.europa.eu/software/page/eupl
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* Licence for the specific language governing permissions and limitations
* under the Licence.
* @licend
* @module
*/
import { Point, Dimension, Rectangle } from '../AWT.js';
const DEFAULT_COMPOSITE_OP = 'source-over';
/**
* BoxConnector allows users to visually connect two {@link module:boxes/ActiveBox.ActiveBox ActiveBox} objects of an
* {@link module:Activity.ActivityPanel ActivityPanel}. There are two modes of operation:
*
* - Drawing a line between an origin point (usually the point where the user clicks on) and a
* destination point.
* - Dragging the ActiveBox from one location to another.
*
* The connecting lines can have arrowheads at its endings.
*/
export class BoxConnector {
/**
* BoxConnector constructor
* @param {module:AWT.Container} parent - The Container to which this BoxConnector belongs
* @param {external:jQuery} $canvas - The HTML `canvas` element where this BoxConnector will draw.
*/
constructor(parent, $canvas) {
this.parent = parent;
this.ctx = $canvas.get(-1).getContext('2d', { willReadFrequently: true });
this.dim = new Dimension(this.ctx.canvas.width, this.ctx.canvas.height);
this.origin = new Point();
this.dest = new Point();
this.relativePos = new Point();
}
/**
* Displaces the ending point of the connector
* @param {number} dx - Displacement on the X axis
* @param {number} dy - Displacement on the Y axis
*/
moveBy(dx, dy) {
this.moveTo(Point(this.dest.x + dx, this.dest.y + dy));
}
/**
* Moves the ending point of the connector to a new position
* @param {module:AWT.Point} pt - The new position
* @param {boolean} forcePaint - When `true`, forces the repaint of all the area also if there is
* no movement at all.
*/
moveTo(pt, forcePaint) {
if (!this.active || !forcePaint && this.dest.equals(pt))
return;
// Restore the background
if (this.bgRect) {
if (this.bgImg) {
this.ctx.putImageData(
this.bgImg,
0, 0,
this.bgRect.pos.x, this.bgRect.pos.y,
this.bgRect.dim.width, this.bgRect.dim.height);
} else if (this.parent)
this.parent.updateContent();
}
this.dest.moveTo(pt);
// Calculate the bounds of the invalidated area after the move:
// Start with the origin point or box area
const pt1 = new Point(this.origin.x - this.relativePos.x, this.origin.y - this.relativePos.y);
this.bgRect = new Rectangle(pt1, this.bx ? this.bx.dim : new Dimension());
// Add the destination point or box area
const pt2 = new Point(pt.x - this.relativePos.x, pt.y - this.relativePos.y);
this.bgRect.add(new Rectangle(pt2, this.bx ? this.bx.dim : new Dimension()));
// Add a generous border around the area
this.bgRect.grow(10, 10);
if (this.bx !== null) {
// Move the ActiveBox
this.bx.moveTo(new Point(pt.x - this.relativePos.x, pt.y - this.relativePos.y));
this.bx.setTemporaryHidden(false);
this.bx.update(this.ctx, null);
this.bx.setTemporaryHidden(true);
} else {
// Draw the connecting line
this.drawLine();
this.linePainted = true;
}
}
/**
* Starts the box connector operation
* @param {module:AWT.Point} pt - Starting point
* @param {module:boxes/ActiveBox.ActiveBox} [box] - Passed only when the BoxConnector runs in drag&drop mode
*/
begin(pt, box) {
if (this.active)
this.end();
this.origin.moveTo(pt);
this.dest.moveTo(pt);
this.linePainted = false;
this.active = true;
if (box) {
// Remember what box will be moved, hide it from the panel and repaint all
this.bx = box;
this.relativePos.moveTo(pt.x - box.pos.x, pt.y - box.pos.y);
this.bx.setFocused(true);
this.bx.setTemporaryHidden(true);
this.linePainted = false;
this.parent.invalidate().update();
}
// Save the full image currently displayed on the panel (with the box hidden)
try {
this.bgImg = this.ctx.getImageData(0, 0, this.dim.width, this.dim.height);
} catch (_ex) {
// Avoid "canvas tainted by cross-origin data" errors
// Setting bgImg to null is less efficient, but works
this.bgImg = null;
}
this.bgRect = null;
// Make a first movement to make the box appear
if (box)
this.moveTo(pt, true);
}
/**
* Finalizes the operation of this box connector until a new call to `begin`
*/
end() {
if (!this.active)
return;
this.active = false;
this.linePainted = false;
this.bgRect = null;
this.bgImg = null;
if (this.bx) {
// Restore the original position and attributes of the box
this.bx.setFocused(false);
this.bx.moveTo(this.origin.x - this.relativePos.x, this.origin.y - this.relativePos.y);
this.bx.setTemporaryHidden(false);
this.bx = null;
this.relativePos.moveTo(0, 0);
}
// Repaint all
this.ctx.clearRect(0, 0, this.dim.width, this.dim.height);
this.parent.invalidate().update();
}
/**
* Strokes a line between `origin` and `dest`, optionally ended with an arrowhead.
*/
drawLine() {
if (this.compositeOp !== DEFAULT_COMPOSITE_OP) {
this.ctx.strokeStyle = this.xorColor;
this.ctx.globalCompositeOperation = this.compositeOp;
} else
this.ctx.strokeStyle = this.lineColor;
this.ctx.lineWidth = this.lineWidth;
this.ctx.beginPath();
this.ctx.moveTo(this.origin.x, this.origin.y);
this.ctx.lineTo(this.dest.x, this.dest.y);
this.ctx.stroke();
if (this.arrow) {
// Draws the arrow head
const
beta = Math.atan2(this.origin.x - this.dest.x, this.dest.x - this.origin.x),
arp = new Point(this.dest.x - this.arrowLength * Math.cos(beta + this.arrowAngle),
this.dest.y + this.arrowLength * Math.sin(beta + this.arrowAngle));
this.ctx.beginPath();
this.ctx.moveTo(this.dest.x, this.dest.y);
this.ctx.lineTo(arp.x, arp.y);
this.ctx.stroke();
arp.moveTo(this.dest.x - this.arrowLength * Math.cos(beta - this.arrowAngle),
this.dest.y + this.arrowLength * Math.sin(beta - this.arrowAngle));
this.ctx.beginPath();
this.ctx.moveTo(this.dest.x, this.dest.y);
this.ctx.lineTo(arp.x, arp.y);
this.ctx.stroke();
}
if (this.compositeOp !== DEFAULT_COMPOSITE_OP) {
// reset default settings
this.ctx.globalCompositeOperation = DEFAULT_COMPOSITE_OP;
}
}
}
Object.assign(BoxConnector.prototype, {
/**
* The background image, saved and redrawn on each movement
* @name module:boxes/BoxConnector.BoxConnector#bgImg
* @type {external:HTMLImageElement} */
bgImg: null,
/**
* The rectangle of {@link module:Activity.ActivityPanel ActivityPanel} saved in `bgImg`
* @name module:boxes/BoxConnector.BoxConnector#bgRect
* @type {module:AWT.Rectangle} */
bgRect: null,
/**
* Initial position of the connector
* @name module:boxes/BoxConnector.BoxConnector#origin
* @type {module:AWT.Point} */
origin: null,
/**
* Current (while moving) and final position of the connector
* @name module:boxes/BoxConnector.BoxConnector#dest
* @type {module:AWT.Point} */
dest: null,
/**
* When `true`, the connector must end on arrowhead
* @name module:boxes/BoxConnector.BoxConnector#arrow
* @type {boolean} */
arrow: false,
/**
* `true` while the connector is active
* @name module:boxes/BoxConnector.BoxConnector#active
* @type {boolean} */
active: false,
/**
* `true` while the line has already been painted (used for XOR expressions)
* @name module:boxes/BoxConnector.BoxConnector#linePainted
* @type {boolean} */
linePainted: false,
/**
* The arrowhead length (in pixels)
* @name module:boxes/BoxConnector.BoxConnector#arrowLength
* @type {number} */
arrowLength: 10,
/**
* The arrowhead angle
* @name module:boxes/BoxConnector.BoxConnector#arrowAngle
* @type {number} */
arrowAngle: Math.PI / 6,
/**
* The main color used in XOR operations
* @name module:boxes/BoxConnector.BoxConnector#lineColor
* @type {string} */
lineColor: 'black',
/**
* The complementary color used in XOR operations
* @name module:boxes/BoxConnector.BoxConnector#xorColor
* @type {string} */
xorColor: 'white',
/**
* The global composite operator used when drawing in XOR mode. Default is "difference".
* For a list of possible values see:
* {@link https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation}
* @name module:boxes/BoxConnector.BoxConnector#compositeOp
* @type {string} */
compositeOp: 'difference',
/**
* The default {@link https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation composite operator}
* ("source-over").
* @name module:boxes/BoxConnector.BoxConnector#DEFAULT_COMPOSITE_OP
* @static
* @type {string} */
DEFAULT_COMPOSITE_OP: DEFAULT_COMPOSITE_OP,
/**
* Relative position of point B regarding A
* @name module:boxes/BoxConnector.BoxConnector#relativePos
* @type {module:AWT.Point} */
relativePos: null,
/**
* The ActiveBox to connect or move
* @name module:boxes/BoxConnector.BoxConnector#bx
* @type {module:boxes/ActiveBox.ActiveBox} */
bx: null,
/**
* The Graphics context where the BoxConnector will paint
* @name module:boxes/BoxConnector.BoxConnector#ctx
* @type {external:CanvasRenderingContext2D} */
ctx: null,
/**
* The dimension of the HTML canvas where to draw
* @name module:boxes/BoxConnector.BoxConnector#dim
* @type {module:AWT.Dimension} */
dim: null,
/**
* The container to which this connector belongs
* @name module:boxes/BoxConnector.BoxConnector#parent
* @type {module:AWT.Container} */
parent: null,
/**
* Width of the connector line
* @name module:boxes/BoxConnector.BoxConnector#lineWidth
* @type {number} */
lineWidth: 1.5,
});
export default BoxConnector;