src/world/World.js
import * as utils from '../utils/Utils';
import * as constants from '../utils/Constants';
import Scale from '../utils/Scale';
import Axis from '../elements/Axis';
import Renderer from '../utils/Renderer';
/**
* The World class handles the canvas and the drawing of elements. It manages all touch and mouse events,
* as well as the scaling and axis settings. All elements drawn to the canvas must go through a World object.
* @public
* @class World
*/
export default class World {
/**
* @constructor
* @param {string} id HTML id of the div where the World will be initiated.
* @param {function} drawCallback Function called every 60fps to draw animations.
* @param {function} resizeCallback Function called every time the canvas gets resized.
*/
constructor(id, drawCallback, resizeCallback) {
/**
* jQuery reference to the div container of the canvas.
* @type {object}
*/
this.container = $(utils.fixId(id));
/**
* HTML canvas created for the world. Important to note is that the canvas does not have an alpha
* channel. This is done to optimize the framerate.
* @type {object}
*/
this.canvas =
document.querySelector(`${utils.fixId(id)} canvas`) ||
document.createElement('canvas', { alpha: false });
/**
* Context of the created canvas for the world.
* @type {object}
*/
this.ctx = this.canvas.getContext('2d');
/**
* Pixel ratio of the device.
* @type {number}
*/
this.pxRatio = utils.getPixelRatio(this.ctx);
/**
* Scale object for the -x axis.
* Default scale is 50px per 1 unit.
* @type {Scale}
*/
this.scaleX = new Scale(50, 1);
/**
* Scale object for the -y axis.
* Default scale is 50px per 1 unit.
* @type {Scale}
*/
this.scaleY = new Scale(50, -1);
/**
* Width of the canvas in pixels.
* @type {number}
*/
this.width = 0;
/**
* Width before resizing the canvas to the new width. Used to determine if a change
* has occured in the width in order to continue with the resizing process.
* @type {number}
*/
this.prevWidth = 0;
/**
* Height of the canvas in pixels.
* @type {number}
*/
this.height = 0;
/**
* Array of elements added to the world.
* @type {WorldElement[]}
*/
this.elements = [];
/**
* Axis object used to draw the main axis.
* @type {Axis}
*/
this.axis = new Axis();
/**
* Callback function for when the elements are drawn to the canvas.
* @type {function}
*/
this.onDraw = utils.isFunction(drawCallback) ? drawCallback : null;
/**
* Callback function for when the canvas gets resized.
* @type {function}
*/
this.onResize = utils.isFunction(resizeCallback) ? resizeCallback : null;
/**
* Callback function for when the mouse moves over the canvas.
* @type {function}
*/
this.onMouseMove = null;
/**
* Simple background color for the canvas. If set to ther value than {@link COLORS}.WHITE the canvas
* will draw a rectangle with the new color at the beginning of every draw cycle. When the background
* renderer is enabled, the color value is no more relevant.
* @type {string}
*/
this.color = constants.COLORS.WHITE;
/**
* Request id number from the requestAnimationFrame. Used to determine if the animation has
* started.
* @type {number}
*/
this.started = null;
/**
* Flag that stops the execution of the drawing loop.
* @type {boolean}
*/
this.destroyed = false;
/**
* Mouse object containing all mouse properties and values.
* @type {object}
* @property {number} x Current -x position of the mouse in pixels.
* @property {number} y Current -y position of the mouse in pixels.
* @property {number} px Previous -x position of the mouse in pixels.
* @property {number} py Previous -y position of the mouse in pixels.
* @property {number} dx Change in the -x direction in pixels.
* @property {number} dy Change in the -y direction in pixels.
* @property {number} rx Real -x position of the mouse with respect to the axis in units.
* @property {number} ry Real -y position of the mouse with respect to the axis in units.
* @property {boolean} down Flag for when the mouse is pressed down over the canvas.
* @property {boolean} inCanvas Flag for when the mouse is over the canvas.
* @property {object} dragging Contains the currently dragged {@link WorldElement}. If no element is dragged then the value is equal to {@link DRAG_NOTHING}.
* @property {object} over Contains the element that the mouse is currently over. If no element is under the mouse then the value is equal to {@link OVER_NOTHING}.
* @property {string} cursor Current cursor of the canvas. Changes depending if it is over an element.
*/
this.mouse = {
x: 0,
y: 0,
px: 0,
py: 0,
dx: 0,
dy: 0,
rx: 0,
ry: 0,
down: false,
inCanvas: false,
dragging: constants.DRAG_NOTHING,
over: constants.OVER_NOTHING,
cursor: constants.CURSOR.DEFAULT
};
/**
* Background renderer object. It is disabled by default.
* @type {Renderer}
*/
this.background = new Renderer({
absolute: true,
enabled: false,
world: this
});
// Add the canvas to the container.
this.container.append(this.canvas);
// Add the axis to the list of elements.
this.add(this.axis);
// Bind all events to the canvas and force a resize event.
this.bindEventListeners();
this.resize();
}
/**
* Once it has been determined that the mouse is over an element and a dragging process has begun,
* the position of the object must be updated to follow the mouse. The function looks at the value of
* {@link WorldElement.mouseMoveStyle} to determine if the position should be updated in pixels or in
* real values. The position is updated by adding the change in the mouse's position to the element.
* This creates a smoother movement. Finally, the callback function {@link WorldElement.onMouseMove} is
* called.
* @private
*/
moveElements() {
const element = this.mouse.dragging;
if (element) {
if (this.mouse.inCanvas) {
if (element.mouseMoveStyle === constants.MOVE_STYLE.BY_PX) {
element.addPosition(this.mouse.dx, this.mouse.dy);
} else {
element.addPosition(this.mouse.rdx, this.mouse.rdy);
if (!this.axis.negative) {
if (element.position.x < 0) element.position.x = 0;
if (element.position.y < 0) element.position.y = 0;
}
}
if (element.renderer) element.renderer.rendered = false;
if (utils.isFunction(element.onMouseMove)) element.onMouseMove(element);
}
element.dragging = this.mouse.down && this.mouse.inCanvas;
element.mouse_over = element.dragging;
this.mouse.dragging = element.dragging
? this.mouse.dragging
: constants.DRAG_NOTHING;
element.topmost(element.dragging && element.topmostOnDrag);
}
}
/**
* Obtain the mouse or touch coordinates from the event data obtained from the callback. The data
* is obtained in pixels, howevere it is also converted to real units using the scale provided. It
* also calcualtes the previous positio and the change in position in both units (pixels and real units).
* @private
*/
getMousePosition(e) {
const m = this.mouse;
const rect = this.canvas.getBoundingClientRect();
// Determine if it is a touch event or mouse event.
let evt;
if (e && !e.clientX) {
if (e.touches) {
[evt] = e.touches;
} else if (e.changedTouches) {
[evt] = e.changedTouches;
}
} else {
evt = e;
}
// Save previous values
m.px = m.x;
m.py = m.y;
// Calculate position in pixels.
m.x = Math.floor(evt.clientX - rect.left);
m.y = Math.floor(evt.clientY - rect.top);
// Convert position to real units.
m.rx = (m.x - this.axis.position.x) * this.scaleX.toUnits;
m.ry = (m.y - this.axis.position.y) * this.scaleY.toUnits;
// Calculate the delta in pixels.
m.dx = m.x - m.px;
m.dy = m.y - m.py;
// Calculate the delta in real units.
m.rdx = m.dx * this.scaleX.toUnits;
m.rdy = m.dy * this.scaleY.toUnits;
}
/**
* Find if the mouse is over an element. It queries the method {@link WorldElement.isMouseOver} to check
* if the mouse is over the bounding box of the element.
* @private
*/
isMouseOverElement() {
if (this.mouse.dragging !== constants.DRAG_NOTHING) return;
let found = constants.OVER_NOTHING;
let overElement = false;
for (let i = this.elements.length - 1; i >= 0; i -= 1) {
// A Box has multiple elements inside that can be clickable.
if (this.elements[i].elements) {
// Iterate though all elements and find those with click callbacks.
this.elements[i].elements
.filter(e => e.onClick)
.forEach(e => {
if (utils.isFunction(e.isMouseOver)) {
e.mouseOver = e.isMouseOver();
overElement = overElement || e.mouseOver;
}
});
}
// Check if the mouse is over an element.
if (
this.elements[i].isMouseOver() &&
this.elements[i].display &&
this.elements[i].isDraggable &&
found === constants.OVER_NOTHING
) {
found = i;
this.elements[i].mouseOver = true;
} else {
this.elements[i].mouseOver = false;
}
}
// Change the cursor if the mouse is over an element or found an element.
let cursor = constants.CURSOR.DEFAULT;
if (overElement) cursor = constants.CURSOR.POINTER;
if (found !== constants.OVER_NOTHING) cursor = this.elements[found].cursor;
this.setCursor(cursor);
this.mouse.over = found;
}
/**
* Removes all event listeners used by the canvas.
* @private
*/
destroy() {
this.destroyed = true;
this.bindEventListeners(true);
}
/**
* Binds a callback function to the mouse, touch and resize event listeners.
* @private
*/
bindEventListeners(destroy) {
const action = destroy ? 'removeEventListener' : 'addEventListener';
const callbacks = {
mousemove(e) {
this.getMousePosition(e);
this.isMouseOverElement();
this.moveElements();
if (utils.isFunction(this.onMouseMove)) this.onMouseMove();
},
mouseenter(e) {
e.preventDefault();
this.mouse.inCanvas = true;
},
mouseleave(e) {
e.preventDefault();
this.mouse.inCanvas = false;
},
mousedown(e) {
e.preventDefault();
this.getMousePosition(e);
this.isMouseOverElement();
this.mouse.down = true;
if (this.mouse.over !== constants.OVER_NOTHING) {
this.mouse.dragging = this.elements[this.mouse.over];
}
},
mouseup(e) {
e.preventDefault();
this.mouse.down = false;
this.moveElements();
},
touchstart(e) {
this.getMousePosition(e);
this.isMouseOverElement();
this.mouse.down = true;
if (this.mouse.over !== constants.OVER_NOTHING) {
if (e.cancelable) e.preventDefault();
this.mouse.dragging = this.elements[this.mouse.over];
this.mouse.inCanvas = true;
}
},
touchend(e) {
this.mouse.down = false;
if (this.mouse.dragging !== constants.DRAG_NOTHING) {
if (e.cancelable) e.preventDefault();
this.mouse.inCanvas = false;
this.moveElements();
}
}
};
window[action]('resize', this.resize.bind(this), false);
window[action]('mouseup', callbacks.mouseup.bind(this), false);
this.canvas[action]('mousemove', callbacks.mousemove.bind(this), false);
this.canvas[action]('mouseenter', callbacks.mouseenter.bind(this), false);
this.canvas[action]('mouseleave', callbacks.mouseleave.bind(this), false);
this.canvas[action]('mousedown', callbacks.mousedown.bind(this), false);
this.canvas[action]('touchmove', callbacks.mousemove.bind(this), false);
this.canvas[action]('touchstart', callbacks.touchstart.bind(this), false);
this.canvas[action]('touchend', callbacks.touchend.bind(this), false);
}
/**
* Callback function for the resize event. It recalculates the width of the main canvas as well
* as all rendered canvases and their elements. It calls the callback function {@link World.onResize}.
* @private
*/
resize() {
// Make the canvas take the full size of its container.
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
const { width, height } = this.canvas.getBoundingClientRect();
this.prevWidth = this.width;
this.width = width;
this.height = height;
this.pxRatio = utils.getPixelRatio(this.ctx);
this.canvas.width = Math.floor(this.width * this.pxRatio);
this.canvas.height = Math.floor(this.height * this.pxRatio);
this.ctx.scale(this.pxRatio, this.pxRatio);
if (this.background.enabled) {
this.background.resize();
}
for (let i = 0; i < this.elements.length; i++) {
if (utils.isFunction(this.elements[i].resize)) this.elements[i].resize();
if (this.elements[i].renderer) this.elements[i].renderer.resize();
}
const widthChange = Math.abs(this.prevWidth - this.width);
if (this.started && utils.isFunction(this.onResize) && widthChange > 0)
this.onResize();
}
/**
* Sets a new cursor for when the mouse is over the canvas.
* @public
* @param {string} cursor Valid cursor type.
*/
setCursor(cursor) {
if (cursor !== this.mouse.cursor) {
this.canvas.style.cursor = cursor;
this.mouse.cursor = cursor;
}
}
/**
* Main draw function. Runs at 60fps and can't be stopped once the world is started.
* Therefore it is constantly drawing to the canvas. This further optimizes the code needed
* to run simulations.
* Looks at the background object to check if it's enabled. If so, it draws the prerendered image
* to the main canvas, thus avoiding to draw the background every time from scratch.
* The draw method {@link WorldElement.draw} is called for every element added to the world.
* @private
*/
draw() {
this.onDraw();
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.clearRect(0, 0, this.width, this.height);
// Background.
if (this.background.enabled) {
if (this.background.rendered) {
this.background.draw();
} else if (utils.isFunction(this.background.callback)) {
this.background.begin();
this.background.callback(
this.background.ctx,
this.background.callbackArgs
);
this.background.end();
this.background.draw();
}
} else if (this.color !== constants.COLORS.WHITE) {
this.ctx.fillStyle = this.color;
this.ctx.rect(0, 0, this.width, this.height);
this.ctx.fill();
}
this.ctx.save();
this.ctx.translate(this.axis.position.x, this.axis.position.y);
this.elements.sort((a, b) => a.zIndex - b.zIndex);
this.elements
.filter(e => e.display && utils.isFunction(e.draw))
.forEach(e => e.draw());
this.ctx.restore();
if (!this.destroyed) this.start();
}
/**
* Adds a {@link WorldElement} to the world. Multiple elements can be added in a
* single add function, they only need to be separated by comma.
* @public
* @param {...WorldElement} args Elements to add to the world.
*/
add(...args) {
args
.filter(
obj =>
utils.isObject(obj) &&
Object.prototype.hasOwnProperty.call(obj, 'valid')
)
.forEach(obj => {
obj.setWorld(this);
if (obj.renderer) obj.renderer.setWorld(this);
this.elements.push(obj);
});
}
/**
* Remove a {@link WorldElement} from the world. Multiple elements can be removed
* in a single remove function, they only need to be separated by comma.
* @public
* @param {...WorldElement} args Elements to remove from the world.
*/
remove(...args) {
for (let i = 0; i < args.length; i++) {
const index = this.elements.indexOf(args[i]);
if (index > -1) {
this.elements[index].world = undefined;
this.elements.splice(index, 1);
}
}
}
/**
* Starts the animation loop of 60fps.
* @private
*/
start() {
if (this.started === null && utils.isFunction(this.onResize)) {
this.onResize();
}
this.started = window.requestAnimationFrame(this.draw.bind(this));
}
/**
* Creates a screenshot of the canvas and saves it as sc.png in the same folder
* as the main file from the simulation. The canvas data is passed to a php file
* that only runs at localhost.
* @public
*/
export() {
// Split url to get simulation path
// /newtondreams-bs4/fisica/sim/
const urlArray = window.location.pathname.split('/');
const simType = urlArray[1];
const simName = urlArray[2];
$.ajax({
data: {
data: this.canvas.toDataURL(),
path: `${simType}/${simName}/`
},
url: '../../php/export.php',
dataType: 'html',
type: 'post',
success(response) {
console.log(response);
}
});
}
/**
* Makes sure that a box of width (xMin + xMax) and height {yMin + yMax} fits in the canvas.
* This is used when the dimensions of objects changes dynamically.
* @public
* @param {number} xMin Minimum x value required to be displayed.
* @param {number} xMax Maximum x value required to be displayed.
* @param {number} yMin Minimum y value required to be displayed.
* @param {number} yMax Maximum y value required to be displayed.
* @param {number} scale Scale given to the range provided. If the scale is > 1 then the bounding box will be larger than the data and thus will result in a better fit.
*/
fit(xMin, xMax, yMin, yMax, scale) {
const xRange = Math.abs(xMin) + Math.abs(xMax);
const yRange = Math.abs(yMin) + Math.abs(yMax);
const xScale = utils.calcStepSize(
xRange * scale,
this.width / this.scaleX.px
);
const yScale = utils.calcStepSize(
yRange * scale,
this.height / this.scaleY.px
);
this.scaleX.set(
this.scaleX.px,
Number.isNaN(xScale) ? 1 : xScale,
this.scaleX.unit
);
this.scaleY.set(
this.scaleY.px,
Number.isNaN(yScale) ? -1 : -yScale,
this.scaleY.unit
);
const xCenter = Math.floor(
Math.abs(xMin) * this.scaleX.toPx +
(this.width - xRange * this.scaleX.toPx) / 2
);
const yCenter = Math.floor(
Math.abs(yMin) * this.scaleY.toPx +
(this.height - yRange * this.scaleY.toPx) / 2
);
this.axis.setPosition(xCenter, yCenter);
}
}