532 lines
23 KiB
JavaScript
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
|
|
};
|
|
}); |