Files
pathfinder/js/app/map/layout.js

532 lines
23 KiB
JavaScript

define(() => {
'use strict';
class Position {
constructor(config){
this._defaultConfig = {
container: null, // parent DOM container element
center: null, // DOM element OR [x,y] coordinates that works as center
elementClass: 'pf-system', // class for all elements
defaultSteps: 8, // how many potential dimensions are checked on en ellipsis around the center
defaultGapX: 50,
defaultGapY: 50,
gapX: 50, // leave gap between elements (x-axis)
gapY: 50, // leave gap between elements (y-axis)
minX: 0, // min x for valid elements
minY: 0, // min y for valid elements
spacingX: 20, // spacing x between elements
spacingY: 10, // spacing y between elements
loops: 2, // max loops around "center" for search
grid: false, // set to [20, 20] to force grid snapping
newElementWidth: 100, // width for new element
newElementHeight: 22, // height for new element
mirrorSearch: false, // if true coordinates are "mirrored" for an "alternating" search
debug: false, // render debug elements
debugOk: false, // if true, only not overlapped dimensions are rendered for debug
debugElementClass: 'pf-system-debug' // class for debug elements
};
this._config = Object.assign({}, this._defaultConfig, config);
this._config.dimensionCache = {};
this._cacheKey = (dim, depth) => ['dim', dim.left, dim.top, dim.width, dim.height, depth].join('_');
/**
* convert degree into radial unit
* @param deg
* @returns {number}
* @private
*/
this._degToRad = deg => deg * Math.PI / 180;
/**
* get element dimension/position of a DOM element
* @param element
* @param spacingX
* @param spacingY
* @returns {{a: *, b: *, top: *, left: *, width: *, height: *}}
* @private
*/
this._getElementDimension = (element, spacingX = 0, spacingY = 0) => {
let left = 0;
let top = 0;
let a = 0;
let b = 0;
let width = this._config.newElementWidth;
let height = this._config.newElementHeight;
if(Array.isArray(element)){
// x/y coordinates
let point = [
element[0] ? parseInt(element[0], 10) : 0,
element[1] ? parseInt(element[1], 10) : 0
];
if(this._config.grid){
point = this._transformPointToGrid(point);
}
left = point[0];
top = point[1];
a = this._config.gapX;
b = this._config.gapY;
}else if(element instanceof Element){
// DOM element
left = (element.style.left ? parseInt(element.style.left, 10) : 0) - spacingX;
top = (element.style.top ? parseInt(element.style.top, 10) : 0) - spacingY;
a = parseInt((element.offsetWidth / 2).toString(), 10) + spacingX + this._config.gapX;
b = parseInt((element.offsetHeight / 2).toString(), 10) + spacingY + this._config.gapY;
width = element.offsetWidth + 2 * spacingX;
height = element.offsetHeight + 2 * spacingY;
}else if(element instanceof Object){
left = element.left - spacingX;
top = element.top - spacingY;
a = parseInt((element.width / 2).toString(), 10) + spacingX + this._config.gapX;
b = parseInt((element.height / 2).toString(), 10) + spacingY + this._config.gapY;
width = element.width + 2 * spacingX;
height = element.height + 2 * spacingY;
}
// add "gap" to a and b in order to have some space around elements
return {
left: left,
top: top,
a: a,
b: b,
width: width,
height: height
};
};
/**
* get x/y coordinate on an eclipse around a 2D area by a given radial angle
* @param dim
* @param angle
* @returns {*}
* @private
*/
this._getEllipseCoordinates = (dim, angle) => {
let coordinates = null;
if(dim){
angle = this._degToRad(angle);
coordinates = {
x: Math.round((dim.a * dim.b) / Math.sqrt(Math.pow(dim.b, 2) + Math.pow(dim.a, 2) * Math.pow(Math.tan(angle), 2) )),
y: Math.round((dim.a * dim.b) / Math.sqrt(Math.pow(dim.a, 2) + Math.pow(dim.b, 2) / Math.pow(Math.tan(angle), 2) ))
};
// invert coordinate based on quadrant ------------------------------------------------------------
if( angle > (Math.PI / 2) && angle < (3 * Math.PI / 2) ){
coordinates.x = coordinates.x * -1;
}
if( angle > Math.PI && angle < (2 * Math.PI) ){
coordinates.y = coordinates.y * -1;
}
}
return coordinates;
};
/**
* get dimensions of all surrounding elements
* @returns {Array}
* @private
*/
this._getAllElementDimensions = () => {
let dimensions = [];
let surroundingElements = this._getContainer().getElementsByClassName(this._config.elementClass);
for(let element of surroundingElements){
dimensions.push(this._getElementDimension(element, this._config.spacingX, this._config.spacingY));
}
return dimensions;
};
/**
* transform a x/y point into a x/y point that snaps to grid
* @param point
* @returns {*}
* @private
*/
this._transformPointToGrid = point => {
point[0] = Math.floor(point[0] / this._config.grid[0]) * this._config.grid[0];
point[1] = Math.floor(point[1] / this._config.grid[1]) * this._config.grid[1];
return point;
};
/**
* Transform a x/y coordinate into a 2D element with width/height
* @param centerDimension
* @param coordinate
* @returns {*}
* @private
*/
this._transformCoordinate = (centerDimension, coordinate) => {
let dim = null;
if(centerDimension && coordinate){
let left = 0;
let top = 0;
// calculate left/top based on coordinate system quadrant -----------------------------------------
// -> flip horizontal in Q2 and Q3
if(coordinate.x >= 0 && coordinate.y > 0){
// 1. quadrant
left = centerDimension.left + centerDimension.a - this._config.gapX + coordinate.x;
top = centerDimension.top + 2 * (centerDimension.b - this._config.gapY) - Math.abs(coordinate.y) - this._config.newElementHeight;
}else if(coordinate.x < 0 && coordinate.y > 0){
// 2. quadrant
left = centerDimension.left + centerDimension.a - this._config.gapX + coordinate.x - this._config.newElementWidth;
top = centerDimension.top + 2 * (centerDimension.b - this._config.gapY) - Math.abs(coordinate.y) - this._config.newElementHeight;
}else if(coordinate.x < 0 && coordinate.y <= 0){
// 3. quadrant
left = centerDimension.left + centerDimension.a - this._config.gapX + coordinate.x - this._config.newElementWidth;
top = centerDimension.top + Math.abs(coordinate.y);
}else{
// 4. quadrant
left = centerDimension.left + centerDimension.a - this._config.gapX + coordinate.x;
top = centerDimension.top + Math.abs(coordinate.y);
}
// center horizontal for x = 0 coordinate (top and bottom element) --------------------------------
if(coordinate.x === 0){
left -= Math.round(this._config.newElementWidth / 2);
}
// transform to grid coordinates (if grid snapping is enabled) ------------------------------------
if(this._config.grid){
let point = this._transformPointToGrid([left, top]);
left = point[0];
top = point[1];
}
dim = {
left: left,
top: top,
width: this._config.newElementWidth,
height: this._config.newElementHeight
};
}
return dim;
};
/**
* calc overlapping percent of two given dimensions
* @param dim1
* @param dim2
* @returns {number}
* @private
*/
this._percentCovered = (dim1, dim2) => {
let percent = 0;
if(
(dim1.left <= dim2.left) &&
(dim1.top <= dim2.top) &&
((dim1.left + dim1.width) >= (dim2.left + dim2.width)) &&
((dim1.top + dim1.height) > (dim2.top + dim2.height))
){
// The whole thing is covering the whole other thing
percent = 100;
}else{
// Only parts may be covered, calculate percentage
dim1.right = dim1.left + dim1.width;
dim1.bottom = dim1.top + dim1.height;
dim2.right = dim2.left + dim2.width;
dim2.bottom = dim2.top + dim2.height;
let l = Math.max(dim1.left, dim2.left);
let r = Math.min(dim1.right, dim2.right);
let t = Math.max(dim1.top, dim2.top);
let b = Math.min(dim1.bottom, dim2.bottom);
if(b >= t && r >= l){
percent = (((r - l) * (b - t)) / (dim2.width * dim2.height)) * 100;
}
}
return percent;
};
/**
* checks whether dim11 has valid x/y coordinate
* -> coordinates are >= "minX/Y" limit
* @param dim1
* @returns {*|boolean}
* @private
*/
this._valid = dim1 => dim1 && dim1.left >= this._config.minX && dim1.top >= this._config.minY;
/**
* checks whether dim1 is partially overlapped by any other element
* @param dim1
* @param dimensionContainer
* @param allDimensions
* @param depth
* @returns {boolean}
* @private
*/
this._isOverlapping = (dim1, dimensionContainer, allDimensions, depth) => {
let isOverlapping = false;
if(dim1){
let cacheKey = this._cacheKey(dim1, depth);
// check cache first (e.g. if grid is active some dimensions would be checked multiple times)
if(this._config.dimensionCache[cacheKey]){
return true;
}else if(this._percentCovered(dimensionContainer, dim1) === 100){
// element is within parent container
for(let dim2 of allDimensions){
let percentCovered = this._percentCovered(dim1, dim2);
if(percentCovered){
isOverlapping = true;
this._config.dimensionCache[cacheKey] = percentCovered;
break;
}
}
}else{
isOverlapping = true;
this._config.dimensionCache[cacheKey] = 100;
}
}else{
isOverlapping = true;
}
return isOverlapping;
};
/**
*
* @param dim1
* @returns {boolean}
* @private
*/
this._existDimension = function(dim1){
return (
dim1.left === this.left &&
dim1.top === this.top &&
dim1.width === this.width &&
dim1.height === this.height
);
};
/**
* find all dimensions around a centerDimension that are not overlapped by other elements
* @param maxResults
* @param steps
* @param allDimensions
* @param depth
* @param loops
* @returns {Array}
* @private
*/
this._findDimensions = (maxResults, steps, allDimensions, depth, loops) => {
steps = steps || 1;
loops = loops || 1;
let dimensions = [];
let start = 0;
let end = 360;
let angle = end / steps;
// as default coordinates get checked clockwise Q4 -> Q3 -> Q2 -> Q1
// we could also check "mirrored" coordinates Q4+Q1 -> Q3+Q2
if(this._config.mirrorSearch){
end /= end;
}
let dimensionContainer = this._getElementDimension(this._getContainer());
if(loops === 1){
// check center element
let centerDimension = this._getElementDimension(this._config.center);
if(
this._valid(centerDimension) &&
!dimensions.some(this._existDimension, centerDimension) &&
!this._isOverlapping(centerDimension, dimensionContainer, allDimensions, depth)
){
dimensions.push({
left: centerDimension.left,
top: centerDimension.top,
width: centerDimension.width,
height: centerDimension.height
});
this._config.dimensionCache[this._cacheKey(centerDimension, depth)] = 0;
maxResults--;
}
}
// increase the "gab" between center element and potential found dimensions...
// ... for each recursive loop call, to get an elliptical cycle beyond
this._config.gapX = this._config.defaultGapX + (loops - 1) * 20;
this._config.gapY = this._config.defaultGapY + (loops - 1) * 20;
let centerDimension = this._getElementDimension(this._config.center);
while(maxResults > 0 && start < end){
// get all potential coordinates on an eclipse around a given "centerElementDimension"
let coordinate = this._getEllipseCoordinates(centerDimension, end);
let coordinates = [coordinate];
if(this._config.mirrorSearch && coordinate){
coordinates.push({x: coordinate.x, y: coordinate.y * -1 });
}
for(let coordinateTemp of coordinates){
// transform relative x/y coordinate into a absolute 2D area
let checkDimension = this._transformCoordinate(centerDimension, coordinateTemp);
if(
this._valid(checkDimension) &&
!dimensions.some(this._existDimension, checkDimension) &&
!this._isOverlapping(checkDimension, dimensionContainer, allDimensions, depth)
){
dimensions.push({
left: checkDimension.left,
top: checkDimension.top,
width: checkDimension.width,
height: checkDimension.height
});
this._config.dimensionCache[this._cacheKey(checkDimension, depth)] = 0;
maxResults--;
}
}
end -= angle;
}
if(maxResults > 0 && loops < this._config.loops){
loops++;
steps *= 2;
dimensions = dimensions.concat(this._findDimensions(maxResults, steps, allDimensions, depth, loops));
}
return dimensions;
};
/**
* get container (parent) element
* @returns {*}
* @private
*/
this._getContainer = () => {
return this._config.container ? this._config.container : document.body;
};
/**
* render debug element into parent container
* -> checks overlapping dimension with other elements
* @private
*/
this._showDebugElements = () => {
if(this._config.debug){
let documentFragment = document.createDocumentFragment();
for(let [cacheKey, percentCovered] of Object.entries(this._config.dimensionCache)){
if(this._config.debugOk && percentCovered){
continue;
}
let element = document.createElement('div');
let dimParts = cacheKey.split('_');
element.style.left = dimParts[1] + 'px';
element.style.top = dimParts[2] + 'px';
element.style.width = dimParts[3] + 'px';
element.style.height = dimParts[4] + 'px';
element.style.backgroundColor = Boolean(percentCovered) ? 'rgba(255,0,0,0.1)' : 'rgba(0,255,0,0.4)';
element.style.opacity = Boolean(percentCovered) ? 0.5 : 1;
element.style.zIndex = Boolean(percentCovered) ? 1000 : 2000;
element.style.border = Boolean(percentCovered) ? 'none' : '1px solid rgba(0,255,0,0.3)';
element.innerHTML = Math.round(percentCovered * 100) / 100 + '';
element.classList.add(this._config.debugElementClass);
element.setAttribute('data-depth', dimParts[5]);
documentFragment.appendChild(element);
}
this._getContainer().appendChild(documentFragment);
}
};
/**
* hide all debug elements
* @private
*/
this._hideDebugElements = () => {
let debugElements = this._getContainer().getElementsByClassName(this._config.debugElementClass);
while(debugElements.length > 0){
debugElements[0].parentNode.removeChild(debugElements[0]);
}
};
// public functions ---------------------------------------------------------------------------------------
/**
* search for surrounding, non overlapping dimensions
* @param maxResults
* @param findChain
* @returns {Array}
*/
this.findNonOverlappingDimensions = (maxResults, findChain = false) => {
this._hideDebugElements();
this._config.dimensionCache = {};
// element dimensions that exist and should be checked for overlapping
let allDimensions = this._getAllElementDimensions();
let dimensions = [];
let depth = 1;
let maxDepth = findChain ? maxResults : 1;
maxResults = findChain ? 1 : maxResults;
while(depth <= maxDepth){
let dimensionsTemp = this._findDimensions(maxResults, this._config.defaultSteps, allDimensions, depth);
if(dimensionsTemp.length){
dimensions = dimensions.concat(dimensionsTemp);
if(findChain){
// if depth > 0 we have 2D dimension as "center" (not a x/y coordinate)
// -> increase the gap
this._config.defaultGapX = 10;
this._config.defaultGapY = 10;
this._config.gapX = 50;
this._config.gapY = 50;
this._config.center = dimensionsTemp[0];
allDimensions = allDimensions.concat([this._getElementDimension(dimensionsTemp[0], this._config.spacingX, this._config.spacingY)]);
}
depth++;
}else{
break;
}
}
this._showDebugElements();
return dimensions;
};
}
}
/**
* return mouse coordinates from event
* @param e
* @returns {{x: number, y: number}}
*/
let getEventCoordinates = e => {
let posX = 0;
let posY = 0;
if(e.offsetX && e.offsetY){
// Chrome
posX = e.offsetX;
posY = e.offsetY;
}else if(e.originalEvent){
// Firefox -> #415
posX = e.originalEvent.layerX;
posY = e.originalEvent.layerY;
}
return {x: posX, y: posY};
};
return {
Position: Position,
getEventCoordinates: getEventCoordinates
};
});