src/configurators/Positioning.js
import $ from 'jquery'
import uniq from 'lodash/uniq'
import { cssClasses } from '../globals'
import { ListenerOrganizerMixin } from '../ListenerOrganizerMixin'
import { mixinAsClass } from '../utilities'
/**
* This describes the floating directions of an element. It can be an array, then it will move from the center to the
* first specified direction and after that it moves in the second direction. If it is set to 'fixed' it is not
* positioned via Positioning.
* @typedef {string[]|string} Float
*/
/**
* @typedef {Object} HideableElement
* @property {Control} control
* @property {number} importance
* @property {number} order
* @property {Float} [float] first and second direction or special value 'fixed'
* @property {HideableElement[]} [hideableChildren]
*/
/**
* @typedef {HideableElement} PositionedElement
* @property {string[]} float first and second direction or special value 'fixed'
* @property {number} minWidth
* @property {number} minHeight
* @property {{width: number, height: number}} size
*/
/**
* @typedef {object} PositioningOptions
* @property {number} [padding=5]
* @property {number} [spacing=10]
* @property {HTMLElement} viewport
*/
export class Positioning extends mixinAsClass(ListenerOrganizerMixin) {
/**
* @param {PositioningOptions} options
*/
constructor (options) {
super()
/**
* @type {jQuery|HTMLElement}
* @private
*/
this.$viewport_ = $(options.viewport)
/**
* The padding between the controls
* @type {number}
* @private
*/
this.padding_ = options.padding || 5
/**
* The space between the controls and the edges of the map
* @type {number}
* @private
*/
this.spacing_ = options.spacing || 10
/**
* The hidden controle
* @type {jQuery}
* @private
*/
this.hidden$Elements_ = []
this.init()
}
init () {
this.detachAllListeners()
/**
* @type {HideableElement[]}
* @private
*/
this.all_ = []
/**
* All Controls stored according their position on the map
* @type {object}
* @private
*/
this.corners_ = {
left: {
top: {
counterclockwise: [],
clockwise: []
},
bottom: {
counterclockwise: [],
clockwise: []
}
},
right: {
top: {
counterclockwise: [],
clockwise: []
},
bottom: {
counterclockwise: [],
clockwise: []
}
}
}
/**
* This number tracks the order in which controls are added
* @type {number}
* @private
*/
this.order_ = 0
}
getArray_ (float) {
let x, y, direction
switch (float[0]) {
case 'top':
y = 'top'
x = float[1]
if (x === 'left') {
direction = 'clockwise'
} else if (x === 'right') {
direction = 'counterclockwise'
}
break
case 'right':
x = 'right'
y = float[1]
if (y === 'top') {
direction = 'clockwise'
} else if (y === 'bottom') {
direction = 'counterclockwise'
}
break
case 'bottom':
y = 'bottom'
x = float[1]
if (x === 'left') {
direction = 'counterclockwise'
} else {
direction = 'clockwise'
}
break
case 'left':
x = 'left'
y = float[1]
if (y === 'top') {
direction = 'counterclockwise'
} else {
direction = 'clockwise'
}
}
return this.corners_[x][y][direction]
}
/**
* Add a control to the positioning.
* @param {Control} control
* @param {Object} [parentMeta] the meta information of the parent control
*/
addControl (control, parentMeta) {
// check if control needs to be positioned
if (control.get$Element().parents().hasClass('ol-viewport')) {
if (!parentMeta || !parentMeta.control.isWindowed()) {
// gather metainformation
/** @type {HideableElement} */
const metaElem = {
control,
order: this.order_++,
importance: control.getImportance()
}
control.on('change:visible', e => {
const index = this.hidden$Elements_.indexOf(control.get$Element())
if (e.oldValue && index > -1) {
this.hidden$Elements_.splice(index, 1)
}
this.positionElements()
})
// repositioning if collapsible elements changes size
this.listenAt(control).on('change:size', () => {
this.positionElements()
})
if (!parentMeta) {
const float = metaElem.control.getFloat() || ['top', 'left']
if (float === 'fixed') {
return
}
this.getArray_(float).push(metaElem)
this.all_.push(metaElem)
if (metaElem.control.getControls) {
metaElem.hideableChildren = []
for (const child of metaElem.control.getControls()) {
this.addControl(child, metaElem)
}
}
} else if (!parentMeta.control.isWindowed()) {
metaElem.importance = control.getImportance()
parentMeta.hideableChildren.push(metaElem)
this.all_.push(metaElem)
}
}
}
}
/**
* Gets the element in a corner
* @param {string} x
* @param {string} y
* @returns {HideableElement}
* @private
*/
getCorner_ (x, y) {
let cwi = 0
let ccwi = 0
let cwElem = this.corners_[x][y].clockwise[cwi++]
let ccwElem = this.corners_[x][y].counterclockwise[ccwi++]
while (cwElem && !Positioning.isElemVisible_(cwElem)) {
cwElem = this.corners_[x][y].clockwise[cwi++]
}
while (ccwElem && !Positioning.isElemVisible_(ccwElem)) {
ccwElem = this.corners_[x][y].counterclockwise[ccwi++]
}
if (cwElem || ccwElem) {
if (!cwElem) {
return ccwElem
} else if (!ccwElem) {
return cwElem
} else {
if (cwElem.order < ccwElem.order) {
return cwElem
} else {
return ccwElem
}
}
}
}
static isElemVisible_ (elem) {
return elem.control.get$Element().is(':visible')
}
/**
* Gets all elements at one edge
* @param side
* @returns {*|Array.<Element>}
* @private
*/
getEdge_ (side) {
let x1, x2, y1, y2
if (side === 'top') {
x1 = 'left'
x2 = 'right'
y1 = y2 = 'top'
} else if (side === 'right') {
x1 = x2 = 'right'
y1 = 'top'
y2 = 'bottom'
} else if (side === 'bottom') {
x1 = 'right'
x2 = 'left'
y1 = y2 = 'bottom'
} else if (side === 'left') {
x1 = x2 = 'left'
y1 = 'bottom'
y2 = 'top'
}
const clockwise = this.corners_[x1][y1].clockwise.filter(Positioning.isElemVisible_)
const counterclockwise = this.corners_[x2][y2].counterclockwise.filter(Positioning.isElemVisible_)
const arr = []
let c = this.getCorner_(x1, y1)
if (c) {
arr.push(c)
}
arr.push(...clockwise)
arr.push(...counterclockwise)
c = this.getCorner_(x2, y2)
if (c) {
arr.push(c)
}
return uniq(arr)
}
/**
* Initializes all elements
* @private
*/
beforePositioning_ () {
const elems = new Set(this.all_)
this.hidden$Elements_.forEach($e => $e.removeClass(cssClasses.hidden))
this.hidden$Elements_ = []
/**
* @param {PositionedElement} elem
*/
const forEach = elem => {
if (Positioning.isElemVisible_(elem)) {
if (elem.control.beforePositioning) {
elem.control.beforePositioning()
}
if (elem.hideableChildren) {
for (const child of elem.hideableChildren) {
forEach(child)
}
}
elem.control.get$Element().position({ top: 0, left: 0 })
if (elem.control.release) {
elem.control.release('height')
elem.control.release('width')
}
elem.size = this.measureExpandedElement_(elem)
}
elems.delete(elem)
}
for (const elem of elems) {
forEach(elem)
}
}
/**
* called after positioning
* @private
*/
afterPositioning_ () {
const elems = new Set(this.all_)
/**
* @param {PositionedElement} elem
*/
const forEach = elem => {
if (elem.control.afterPositioning) {
elem.control.afterPositioning()
}
if (elem.hideableChildren) {
for (const child of elem.hideableChildren) {
forEach(child)
}
}
elems.delete(elem)
}
for (const elem of elems) {
forEach(elem)
}
}
/**
* Calculates summed length of all elements on one edge
* @param {PositionedElement[]} edgeElems
* @param {string} dim
* @returns {number}
* @private
*/
calculateLength_ (edgeElems, dim) {
if (edgeElems.length === 0) {
return 0
}
let length = this.padding_ * 2
let firstElement = true
for (const elem of edgeElems) {
length += elem.size[dim]
if (firstElement) {
firstElement = false
} else {
length += this.spacing_
}
}
return length
}
calculateSide_ (side, availableSpace) {
const dim = (side === 'top' || side === 'bottom') ? 'width' : 'height'
let elems = this.getEdge_(side)
let wantedSpace = this.calculateLength_(elems, dim)
let changed = false
while (wantedSpace > availableSpace) {
if (this.squeezeElements_(elems, dim, wantedSpace - availableSpace)) {
break
}
this.hideLeastImportant_(elems)
elems = this.getEdge_(side)
wantedSpace = this.calculateLength_(elems, dim)
changed = true
}
return changed
}
positionElementsCorner_ (x, y) {
const corner = this.getCorner_(x, y)
if (corner) {
let xLength = this.padding_
let yLength = this.padding_
let $elem = corner.control.get$Element()
$elem.removeClass(cssClasses.hidden).css({ [x]: xLength, [y]: yLength })
xLength += $elem.outerWidth() + this.spacing_
yLength += $elem.outerHeight() + this.spacing_
let xDirection, yDirection
if (x === 'left' && y === 'top') {
xDirection = 'clockwise'
yDirection = 'counterclockwise'
} else if (x === 'right' && y === 'top') {
xDirection = 'counterclockwise'
yDirection = 'clockwise'
} else if (x === 'right' && y === 'bottom') {
xDirection = 'clockwise'
yDirection = 'counterclockwise'
} else if (x === 'left' && y === 'bottom') {
xDirection = 'counterclockwise'
yDirection = 'clockwise'
}
// x
for (const elem of this.corners_[x][y][xDirection]
.filter(el => Positioning.isElemVisible_(el) && el !== corner)) {
$elem = elem.control.get$Element()
$elem.css({ [x]: xLength, [y]: this.padding_ })
xLength += $elem.outerWidth() + this.spacing_
}
// y
for (const elem of this.corners_[x][y][yDirection]
.filter(el => Positioning.isElemVisible_(el) && el !== corner)) {
$elem = elem.control.get$Element()
$elem.css({ [x]: this.padding_, [y]: yLength })
yLength += $elem.outerHeight() + this.spacing_
}
}
}
/**
* (Re-)Position the controls on the map
*/
positionElements () {
const width = this.$viewport_.innerWidth()
const height = this.$viewport_.innerHeight()
this.beforePositioning_()
// calculation
const processSides = new Set(['top', 'left', 'bottom', 'right'])
while (processSides.size) {
if (processSides.has('top')) {
if (this.calculateSide_('top', width)) {
processSides.add('left')
processSides.add('right')
}
processSides.delete('top')
}
if (processSides.has('right')) {
if (this.calculateSide_('right', height)) {
processSides.add('top')
processSides.add('bottom')
}
processSides.delete('right')
}
if (processSides.has('bottom')) {
if (this.calculateSide_('bottom', width)) {
processSides.add('left')
processSides.add('right')
}
processSides.delete('bottom')
}
if (processSides.has('left')) {
if (this.calculateSide_('left', height)) {
processSides.add('top')
processSides.add('bottom')
}
processSides.delete('left')
}
}
// positioning
this.positionElementsCorner_('left', 'top')
this.positionElementsCorner_('right', 'top')
this.positionElementsCorner_('right', 'bottom')
this.positionElementsCorner_('left', 'bottom')
this.afterPositioning_()
}
/**
* Tries to squeeze the elements on one edge to fit the space. Returns true if it did work, false otherwise.
* @param {PositionedElement[]} elems
* @param {string} side
* @param {number} neededSpace
* @private
* @returns {boolean}
*/
squeezeElements_ (elems, side, neededSpace) {
const squeezableElements = []
function insert (item, x = 0, y = squeezableElements.length) {
if (y === x) {
squeezableElements.splice(x, 0, item)
} else {
const p = Math.floor((x + y) / 2)
if (item.importance <= squeezableElements[p].importance) {
insert(item, x, p)
} else {
insert(item, p + 1, y)
}
}
}
/**
* @param {PositionedElement[]} elems
*/
function findSqueezables (elems) {
for (const elem of elems) {
if (elem.control.isSqueezable && elem.control.isSqueezable(side)) {
insert(elem)
} else if (elem.hideableChildren) {
findSqueezables(elem.hideableChildren)
}
}
}
findSqueezables(elems)
for (const elem of squeezableElements) {
neededSpace -= elem.control.squeezeBy(side, neededSpace)
if (neededSpace <= 0) {
return true
}
}
for (const elem of squeezableElements) {
elem.control.release(side)
}
return false
}
/**
* Expands element to maximum size
* @param {PositionedElement} elem
* @returns {Array}
* @private
*/
expandElement_ (elem) {
const expanded = []
if (elem.hideableChildren) {
for (const child of elem.hideableChildren) {
expanded.push(...this.expandElement_(child))
}
}
if (elem.control.getCollapsible && elem.control.getCollapsible()) {
if (elem.control.getCollapsed()) {
elem.control.setCollapsed(false, true)
expanded.push(elem.control)
}
}
return expanded
}
/**
* measures size of the element
* @param {PositionedElement} elem
* @returns {{width: number, height: number}}
* @private
*/
measureExpandedElement_ (elem) {
const $elem = elem.control.get$Element()
return { width: $elem.outerWidth(), height: $elem.outerHeight() }
}
/**
* Hides the least important element of the given ones.
* @param {PositionedElement[]} elems visible elements
* @private
*/
hideLeastImportant_ (elems) {
let leastImportant = elems[0]
for (const elem of elems.slice(1)) {
if (elem.importance < leastImportant.importance) {
leastImportant = elem
}
}
let childHidden = false
if (leastImportant.hideableChildren) {
const hideableChildren = leastImportant.hideableChildren.filter(Positioning.isElemVisible_)
if (hideableChildren.length > 1) {
this.hideLeastImportant_(hideableChildren)
childHidden = true
}
}
if (childHidden) {
leastImportant.size = this.measureExpandedElement_(leastImportant)
} else {
leastImportant.control.get$Element().addClass(cssClasses.hidden)
this.hidden$Elements_.push(leastImportant.control.get$Element())
}
}
}