@ux-aspects/ux-aspects
Version:
Open source user interface framework for building modern, responsive, mobile big data applications
1,094 lines • 187 kB
JavaScript
import { Injectable } from '@angular/core';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
import { distinctUntilChanged, filter, map, takeUntil } from 'rxjs/operators';
import { tick } from '../../common/index';
import { DashboardStackMode } from './widget/dashboard-stack-mode.enum';
import * as i0 from "@angular/core";
export class DashboardService {
get options() {
return this.options$.getValue();
}
get widgets() {
return this.widgets$.getValue();
}
get stacked() {
return this.stacked$.getValue();
}
get dimensions() {
return this.dimensions$.getValue();
}
get columnWidth() {
return this.dimensions.width / this.options.columns;
}
constructor() {
this._rowHeight = 0;
this.initialized$ = new BehaviorSubject(false);
this.widgets$ = new BehaviorSubject([]);
this.options$ = new BehaviorSubject(defaultOptions);
this.dimensions$ = new BehaviorSubject({});
this.height$ = this.dimensions$.pipe(tick(), map(dimensions => dimensions.height), distinctUntilChanged());
this.placeholder$ = new BehaviorSubject({ visible: false, x: 0, y: 0, width: 0, height: 0 });
this.layout$ = new BehaviorSubject([]);
this.stacked$ = new BehaviorSubject(false);
this.isDragging$ = new BehaviorSubject(null);
this.isGrabbing$ = new BehaviorSubject(null);
this.userLayoutChange$ = new Subject();
/** Unsubscribe from all observables on destroy */
this._onDestroy = new Subject();
combineLatest(this.layout$, this.widgets$, this.initialized$)
.pipe(tick(), filter(([layout, widgets, initialized]) => layout && widgets.length > 0 && initialized), takeUntil(this._onDestroy))
.subscribe(([layout]) => {
this.setLayoutData(layout);
this.renderDashboard();
});
this.stacked$
.pipe(distinctUntilChanged(), filter(stacked => stacked === true), takeUntil(this._onDestroy)).subscribe(this.updateWhenStacked.bind(this));
this.widgets$.pipe(tick(), takeUntil(this._onDestroy)).subscribe(() => this.renderDashboard());
this.dimensions$.pipe(tick(), takeUntil(this._onDestroy)).subscribe(() => this.renderDashboard());
}
ngOnDestroy() {
this._onDestroy.next();
this._onDestroy.complete();
}
/**
* Add a widget to the dashboard
* @param widget The widget component to add to the dashboard
*/
addWidget(widget) {
this.widgets$.next([...this.widgets$.getValue(), widget]);
}
/**
* Remove a widget from the dashboard
* @param widget The widget to remove
*/
removeWidget(widget) {
this.widgets$.next(this.widgets$.getValue().filter(_widget => _widget !== widget));
}
/**
* Indicate that the dashboard element has been resized
* @param width The width of the dashboard element in px
* @param height The height of the dashboard element in px
*/
setDimensions(width = this.dimensions.width, height = this.dimensions.height) {
if (this.dimensions.width !== width || this.dimensions.height !== height) {
this.dimensions$.next({ width, height });
}
}
/**
* Produce an object containing all the required layout data.
* This can be useful for exporting/saving a layout
*/
getLayoutData() {
return this.widgets.map(widget => {
return {
id: widget.id,
col: widget.getColumn(),
row: widget.getRow(),
colSpan: widget.getColumnSpan(),
rowSpan: widget.getRowSpan(),
minColSpan: widget.minColSpan,
minRowSpan: widget.minRowSpan
};
});
}
/**
* Position widgets programatically
*/
setLayoutData(widgets) {
// iterate through each widget data and find a match
widgets.forEach(widget => {
// find the matching widget
const target = this.widgets.find(_widget => _widget.id === widget.id);
if (target) {
target.setColumn(widget.col);
target.setRow(widget.row);
target.setColumnSpan(widget.colSpan);
target.setRowSpan(widget.rowSpan);
target.minColSpan = widget.minColSpan ?? 1;
target.minRowSpan = widget.minRowSpan ?? 1;
}
});
}
/**
* Update the positions and sizes of the widgets
*/
renderDashboard() {
// get the dimensions of the dashboard
this._rowHeight = this.options.rowHeight || this.columnWidth;
// ensure the column width is not below the min widths
this.stacked$.next(this.columnWidth < this.options.minWidth);
this.setDashboardLayout();
// iterate through each widget and set the size - except the one being resized
this.widgets.filter(widget => !this._actionWidget || widget !== this._actionWidget.widget)
.forEach(widget => widget.render());
}
/**
* Determine where widgets should be positioned based on their positions, width and the size of the container
*/
setDashboardLayout() {
// find any widgets that do not currently have a position set
this.widgets.filter(widget => widget.getColumn() === undefined || widget.getRow() === undefined)
.forEach(widget => this.setWidgetPosition(widget));
this.setDashboardHeight();
}
updateWhenStacked() {
// iterate through each widget set it's stacked state and retain the rowSpan
this.getWidgetsByOrder().forEach((widget, idx, widgets) => {
const widgetsAbove = widgets.slice(0, idx);
const row = widgetsAbove.reduce((currentRow, _widget) => currentRow + _widget.getRowSpan(), 0);
widget.setColumn(0);
widget.setRow(row);
});
}
/** Get widgets in the order they visually appear as the widgets array order does not reflect this */
getWidgetsByOrder() {
return [...this.widgets].sort((w1, w2) => {
const w1Position = w1.getColumn(DashboardStackMode.Regular) + (w1.getRow(DashboardStackMode.Regular) * this.options.columns);
const w2Position = w2.getColumn(DashboardStackMode.Regular) + (w2.getRow(DashboardStackMode.Regular) * this.options.columns);
if (w1Position < w2Position) {
return -1;
}
if (w1Position > w2Position) {
return 1;
}
return 0;
});
}
/**
* Find a position that a widget can fit in the dashboard
* @param widget The widget to try and position
*/
setWidgetPosition(widget) {
// find a position for the widget
let position = 0;
let success = false;
// repeat until a space is found
while (!success) {
// get a position to try
const column = position % this.options.columns;
const row = Math.floor(position / this.options.columns);
// check the current position
if (this.getPositionAvailable(column, row, widget.getColumnSpan(), widget.getRowSpan())) {
success = true;
widget.setColumn(column);
widget.setRow(row);
return;
}
if (column === 0 && widget.colSpan > this.options.columns) {
throw new Error('Dashboard widgets have a colSpan greater than the max number of dashboard columns!');
}
position++;
}
}
/**
* Check if a position in the dashboard is vacant or not
*/
getPositionAvailable(column, row, columnSpan, rowSpan, ignoreWidget) {
// get a list of grid spaces that are populated
const spaces = this.getOccupiedSpaces();
// check if the block would still be in bounds
if (column + columnSpan > this.options.columns) {
return false;
}
// check each required position
for (let x = column; x < column + columnSpan; x++) {
for (let y = row; y < row + rowSpan; y++) {
if (spaces.find(block => block.column === x && block.row === y && block.widget !== ignoreWidget)) {
return false;
}
}
}
return true;
}
getOccupiedSpaces() {
// find all spaces that are currently occupied
return this.widgets.filter(widget => widget.getColumn() !== undefined && widget.getRow() !== undefined)
.reduce((value, widget) => {
this.forEachBlock(widget, (column, row) => value.push({ widget, column, row }));
return value;
}, []);
}
/**
* Begin resizing a widget
* @param action The the widget to resize
*/
onResizeStart(action) {
this.cacheWidgets();
// store the mouse event
this._event = action.event;
this._actionWidget = action;
// bring the widget to the font
this.bringToFront(action.widget);
}
onResizeDrag(action) {
const mousePosX = this._event.pageX - pageXOffset;
const mousePosY = this._event.pageY - pageYOffset;
// if there was no movement then do nothing
if (action.event.x === mousePosX && action.event.y === mousePosY) {
return;
}
// update the stored mouse event
this._event = action.event;
// get handle for direction
const { handle } = action;
// get the bounds of the handle
const bounds = handle.getBoundingClientRect();
// get the center of the handle
const centerX = bounds.left + (bounds.width / 2);
const centerY = bounds.top + (bounds.height / 2);
// get the current mouse position
const mouseX = mousePosX - centerX;
const mouseY = mousePosY - centerY;
// store the new proposed dimensions for the widget
const dimensions = {
x: action.widget.x,
y: action.widget.y,
width: action.widget.width,
height: action.widget.height
};
// update widget based on the handle being dragged
switch (action.direction) {
case ActionDirection.Right:
dimensions.width += mouseX;
break;
case ActionDirection.Left:
dimensions.x += mouseX;
dimensions.width -= mouseX;
if (dimensions.width < this.options.minWidth) {
const difference = this.options.minWidth - dimensions.width;
dimensions.x -= difference;
dimensions.width += difference;
}
break;
case ActionDirection.Bottom:
dimensions.height += mouseY;
break;
case ActionDirection.Top:
dimensions.y += mouseY;
dimensions.height -= mouseY;
if (dimensions.height < this.options.minHeight) {
const difference = this.options.minHeight - dimensions.height;
dimensions.y -= difference;
dimensions.height += difference;
}
break;
// Support resizing on multiple axis simultaneously
case ActionDirection.TopLeft:
dimensions.x += mouseX;
dimensions.width -= mouseX;
if (dimensions.width < this.options.minWidth) {
const difference = this.options.minWidth - dimensions.width;
dimensions.x -= difference;
dimensions.width += difference;
}
dimensions.y += mouseY;
dimensions.height -= mouseY;
if (dimensions.height < this.options.minHeight) {
const difference = this.options.minHeight - dimensions.height;
dimensions.y -= difference;
dimensions.height += difference;
}
break;
case ActionDirection.TopRight:
dimensions.width += mouseX;
dimensions.y += mouseY;
dimensions.height -= mouseY;
if (dimensions.height < this.options.minHeight) {
const difference = this.options.minHeight - dimensions.height;
dimensions.y -= difference;
dimensions.height += difference;
}
break;
case ActionDirection.BottomLeft:
dimensions.height += mouseY;
dimensions.x += mouseX;
dimensions.width -= mouseX;
if (dimensions.width < this.options.minWidth) {
const difference = this.options.minWidth - dimensions.width;
dimensions.x -= difference;
dimensions.width += difference;
}
break;
case ActionDirection.BottomRight:
dimensions.height += mouseY;
dimensions.width += mouseX;
break;
}
const currentWidth = action.widget.x + action.widget.width;
const currentHeight = action.widget.y + action.widget.height;
// ensure values are within the dashboard bounds
if (dimensions.x < 0) {
dimensions.x = 0;
dimensions.width = currentWidth;
}
if (dimensions.y < 0) {
dimensions.y = 0;
dimensions.height = currentHeight;
}
if ((dimensions.x + dimensions.width) > this.dimensions.width) {
dimensions.width = this.dimensions.width - dimensions.x;
}
// if the proposed width is smaller than allowed then reset width to minimum and ignore x changes
if (dimensions.width < this.options.minWidth) {
dimensions.x = action.widget.x;
dimensions.width = this.options.minWidth;
}
// if the proposed height is smaller than allowed then reset height to minimum and ignore y changes
if (dimensions.height < this.options.minHeight) {
dimensions.y = action.widget.y;
dimensions.height = this.options.minHeight;
}
const minWidth = ((this.getColumnWidth() * action.widget.minColSpan) - this.getColumnWidth() / 2) + 1;
const isBelowMinWidth = dimensions.width < minWidth;
if (isBelowMinWidth) {
dimensions.x = action.widget.x;
dimensions.width = minWidth;
}
const minHeight = ((this.options.rowHeight * action.widget.minRowSpan) - this.options.rowHeight / 2) + 1;
const isBelowMinHeight = dimensions.height < minHeight;
if (isBelowMinHeight) {
dimensions.y = action.widget.y;
dimensions.height = minHeight;
}
// update the widget actual values
action.widget.setBounds(dimensions.x, dimensions.y, dimensions.width, dimensions.height);
// update placeholder position and value
this.setPlaceholderBounds(true, dimensions.x, dimensions.y, dimensions.width, dimensions.height);
// show the widget positions if the current positions and sizes were to persist
this.updateWidgetPositions(action.widget);
}
onResizeEnd() {
const placeholder = this.placeholder$.getValue();
// commit resize changes
this.commitWidgetChanges();
// hide placeholder
placeholder.visible = false;
// update the placeholder
this.placeholder$.next(placeholder);
this._actionWidget = null;
this._event = null;
// ensure any vacant upper spaces are filled where required
this.shiftWidgetsUp();
// update dashboard height
this.setDashboardHeight();
// emit information about the layout
this.layout$.next(this.getLayoutData());
this.userLayoutChange$.next(this.getLayoutData());
}
onDragStart(action) {
this.onResizeStart(action);
// store the starting placeholder position
this.setWidgetOrigin();
this.cacheWidgets();
// emit the widget we are dragging
this.isDragging$.next(action.widget);
}
onDragEnd() {
this.onResizeEnd();
this._widgetOrigin = {};
this.isDragging$.getValue().sendToBack();
this.isDragging$.next(null);
this.userLayoutChange$.next(this.getLayoutData());
}
onDrag(action) {
// if there was no movement then do nothing
if (action.event.clientX === this._event.clientX && action.event.clientY === this._event.clientY) {
return;
}
// get the current mouse position
const mouseX = action.event.clientX - this._event.clientX;
const mouseY = action.event.clientY - this._event.clientY;
// store the latest event
this._event = action.event;
this.moveWidget(action.widget, mouseX, mouseY);
}
onDragScroll(widget, event) {
this.moveWidget(widget, event.offsetX, event.offsetY);
}
getRowHeight() {
return this._rowHeight;
}
cacheWidgets() {
this._cache = this.widgets.map(widget => ({
id: widget.id,
column: widget.getColumn(),
row: widget.getRow(),
columnSpan: widget.getColumnSpan(),
rowSpan: widget.getRowSpan(),
}));
// return a new array of the cache for custom caching
return [...this._cache];
}
restoreWidgets(ignoreActionWidget = false, cache = this._cache, restoreSize = false) {
cache.filter(widget => !ignoreActionWidget || widget.id !== this._actionWidget.widget.id).forEach(widget => {
const match = this.widgets.find(wgt => wgt.id === widget.id);
if (match) {
match.setColumn(widget.column);
match.setRow(widget.row);
if (restoreSize) {
match.setColumnSpan(widget.columnSpan);
match.setRowSpan(widget.rowSpan);
}
}
});
}
/**
* Return the set of widgets which overlap the given dashboard region.
*/
getOverlappingWidgets(region, actionWidget) {
const widgetsToMove = [];
// check if there are any widgets overlapping widgets
for (let row = region.row; row < region.row + region.rowSpan; row++) {
for (let column = region.column; column < region.column + region.columnSpan; column++) {
// store reference to any widgets that need moved
this.getOccupiedSpaces()
.filter(space => space.column === column && space.row === row && space.widget !== actionWidget)
.forEach(space => widgetsToMove.push(space.widget));
}
}
// remove any duplicates
return widgetsToMove.filter((wgt, idx, array) => array.indexOf(wgt) === idx);
}
/**
* Resolve any overlapping widgets after a widget changes rowSpan/colSpan.
*/
resizeWidget(widget) {
// make widget action and origin widget, direction is irrelevant to this function so set to 0
this._actionWidget = { widget, direction: 0 };
this._widgetOrigin = widget;
const widgetRegion = {
row: widget.row,
column: widget.col,
rowSpan: widget.rowSpan,
columnSpan: widget.colSpan
};
let done = false;
const ITERATION_LIMIT = 100;
for (let i = 0; i <= ITERATION_LIMIT; i++) {
// Check for overlapping widgets and move them. This may need several iterations.
this.shiftWidgetsFromRegion(widgetRegion, widget);
done = this.getOverlappingWidgets(widgetRegion, widget).length === 0;
if (done) {
break;
}
}
if (!done) {
throw new Error('Unable to resolve overlapping widgets!');
}
this.shiftWidgetsUp();
// clear the action and origin widget once we are done
this._actionWidget = undefined;
this._widgetOrigin = undefined;
}
/**
* Move any widgets which intersect with the given dashboard region.
*/
shiftWidgetsFromRegion(region, actionWidget, validatePosition) {
const widgetsToMove = this.getOverlappingWidgets(region, actionWidget);
// if no widgets need moved then we can stop here
if (widgetsToMove.length === 0) {
return;
}
// create a duplicate we can use to keep track of which have been moved
const unmovedWidgets = widgetsToMove.slice();
// attempt to move any widgets to the previous widget position
widgetsToMove.forEach(widget => {
// get a grid off all occupied spaces - taking into account the placeholder and ignoring widgets that need moved
const grid = this.getOccupiedSpaces().filter(space => !unmovedWidgets.find(wgt => wgt === space.widget));
// iterate each free block
for (let row = this._widgetOrigin.row; row < this._widgetOrigin.row + this._widgetOrigin.rowSpan; row++) {
for (let column = this._widgetOrigin.column; column < this._widgetOrigin.column + this._widgetOrigin.columnSpan; column++) {
// determine if the block can fit in this space
const requiredSpaces = this.getRequiredSpacesFromPoint(widget, column, row);
// check if widget would fit in space
const available = requiredSpaces.every(space => {
return !grid.find(gridSpace => gridSpace.column === space.column && gridSpace.row === space.row) && space.column < this.getColumnCount();
});
if (available) {
widget.setColumn(column);
widget.setRow(row);
unmovedWidgets.splice(unmovedWidgets.findIndex(wgt => wgt === widget), 1);
return;
}
}
}
// if we get to here then we can't simply swap the positions - next try moving right
if (this.canWidgetMoveRight(widget, true)) {
// after the shift check if placeholder position is still valid
validatePosition?.(ActionDirection.Right);
return;
}
// next try moving left
if (this.canWidgetMoveLeft(widget, true)) {
// after the shift check if placeholder position is still valid
validatePosition?.(ActionDirection.Left);
return;
}
// determine the distance that the widget needs to be moved down
const distance = (this._actionWidget.widget.getRow() - widget.getRow()) + this._actionWidget.widget.getRowSpan();
// as a last resort move the widget downwards
this.moveWidgetDown(widget, distance);
});
}
/**
* When dragging any widgets that need to be moved should be moved to an appropriate position
*/
shiftWidgets() {
const placeholder = this.placeholder$.getValue();
this.shiftWidgetsFromRegion(placeholder, this._actionWidget.widget, this.validatePlaceholderPosition.bind(this));
}
/**
* After shifts have taken place we should verify the place holder position is still valid
* @param shiftDirection - the position widgets were shifted
*/
validatePlaceholderPosition(shiftDirection) {
const placeholder = this.placeholder$.getValue();
// check if the placeholder is over a widget
if (this.getWidgetsAtPosition(placeholder.column, placeholder.row, true).length > 0) {
// move the placeholder the opposite direction
switch (shiftDirection) {
case ActionDirection.Left:
this.setPlaceholderBounds(placeholder.visible, placeholder.x + this.getColumnWidth(), placeholder.y, placeholder.width, placeholder.height);
break;
case ActionDirection.Right:
this.setPlaceholderBounds(placeholder.visible, placeholder.x - this.getColumnWidth(), placeholder.y, placeholder.width, placeholder.height);
break;
}
// validate this new position again
this.validatePlaceholderPosition(shiftDirection);
}
}
/**
* Determine if a widget can be moved left - or if it can move the widgets to the right to make space for the widget
*/
canWidgetMoveLeft(widget, performMove = false, shift = 0) {
const actionWgt = this._widgetOrigin;
let colShift = shift;
// check if the widget is the action widget or occupies the first column
if (widget === this._actionWidget.widget || widget.getColumn() === 0) {
return false;
}
// if value has been provided skip this step
if (colShift === 0) {
// work out how far the widget is planning to move left
if (actionWgt.row !== widget.getRow()) {
// if the widgets aren't on the same row work out the difference
if (actionWgt.column === widget.getColumn()) {
// if the widgets occupy the same column then shift the widget of the action widget
colShift = actionWgt.columnSpan;
}
else {
// else work out the exact number of spaces it will move left
const widgetDifference = actionWgt.column - widget.getColumn();
colShift = widget.getColumnSpan() - widgetDifference;
}
}
else {
// if they are on the same row then move one row
colShift = 1;
}
}
if (isNaN(colShift) || colShift === 0) {
return;
}
// find the positions required
const targetSpaces = this.getOccupiedSpaces().filter(space => space.widget === widget).map(space => {
return { column: space.column - colShift, row: space.row, widget: space.widget };
});
// check if any of the target spaces are out of bounds
if (targetSpaces.find(space => space.column < 0)) {
return false;
}
// check if there are widget in the required positions and if so, can they move right?
const moveable = targetSpaces.every(space => this.getWidgetsAtPosition(space.column, space.row).filter(wgt => wgt !== space.widget).every(wgt => this.canWidgetMoveLeft(wgt, false, colShift)));
if (performMove && moveable) {
// move all widgets to the left
targetSpaces.forEach(space => this.getWidgetsAtPosition(space.column, space.row).filter(wgt => wgt !== space.widget).forEach(wgt => this.canWidgetMoveLeft(wgt, true, colShift)));
// find the target column
const column = targetSpaces.reduce((target, space) => Math.min(target, space.column), Infinity);
// move current widget to the left
if (column !== Infinity) {
widget.setColumn(column);
}
}
return moveable;
}
/**
* Determine if a widget can be moved right - or if it can move the widgets to the right to make space for the widget
*/
canWidgetMoveRight(widget, performMove = false, shift = 0) {
const actionWgt = this._widgetOrigin;
let colShift = shift;
// check if the widget is the dragging widget or the widget occupies the final column
if (widget === this._actionWidget.widget || widget.getColumn() + widget.getColumnSpan() === this.options.columns) {
return false;
}
// if value has been provided skip this step
if (colShift === 0) {
// work out how far the widget is planning to move right
if (actionWgt.row !== widget.getRow()) {
// if the widgets aren't on the same row work out the difference
if (actionWgt.column === widget.getColumn()) {
// if the widgets occupy the same column then shift the widget of the action widget
colShift = actionWgt.columnSpan;
}
else {
// else work out the exact number of spaces it will move right
const widgetDifference = widget.getColumn() - actionWgt.column;
colShift = actionWgt.columnSpan - widgetDifference;
}
}
else {
// if they are on the same row then move one row
colShift = 1;
}
}
if (isNaN(colShift) || colShift === 0) {
return;
}
// find the positions required
const targetSpaces = this.getOccupiedSpaces().filter(space => space.widget === widget).map(space => {
return { column: space.column + colShift, row: space.row, widget: space.widget };
});
// check if any of the target spaces are out of bounds
if (targetSpaces.find(space => space.column >= this.getColumnCount())) {
return false;
}
// check if there are widget in the required positions and if so, can they move right?
const moveable = targetSpaces.every(space => this.getWidgetsAtPosition(space.column, space.row)
.filter(wgt => wgt !== space.widget)
.every(wgt => this.canWidgetMoveRight(wgt, false, colShift)));
if (performMove && moveable) {
// move all widgets to the right
targetSpaces.forEach(space => this.getWidgetsAtPosition(space.column, space.row).filter(wgt => wgt !== space.widget).forEach(wgt => this.canWidgetMoveRight(wgt, true, colShift)));
// move current widget to the right
widget.setColumn(widget.getColumn() + colShift);
}
return moveable;
}
/**
* Store the initial position of the widget being dragged
*/
setWidgetOrigin() {
this._widgetOrigin = {
column: this._actionWidget.widget.getColumn(),
row: this._actionWidget.widget.getRow(),
columnSpan: this._actionWidget.widget.getColumnSpan(),
rowSpan: this._actionWidget.widget.getRowSpan()
};
}
/**
* Calculate all the required positions is a widget was to be positioned at a particular point
*/
getRequiredSpacesFromPoint(widget, column, row) {
const spaces = [];
for (let y = row; y < row + widget.getRowSpan(); y++) {
for (let x = column; x < column + widget.getColumnSpan(); x++) {
spaces.push({ column: x, row: y, widget });
}
}
return spaces;
}
/**
* Position widgets based on the position of the placeholder - this is temporary until confirmed
*/
updateWidgetPositions(widget) {
const placeholder = this.placeholder$.getValue();
// check all spaces the placeholder will occupy and move any widget currently in them down
for (let column = placeholder.column; column < placeholder.column + placeholder.columnSpan; column++) {
for (let row = placeholder.row; row < placeholder.row + placeholder.rowSpan; row++) {
this.getWidgetsAtPosition(column, row, true)
.filter(wgt => wgt !== widget)
.forEach(wgt => this.moveWidgetDown(wgt));
}
}
// update the height of the dashboard
this.setDashboardHeight();
// if we arent dragging the top handle then fill spaces
if (this._actionWidget.direction !== ActionDirection.Top &&
this._actionWidget.direction !== ActionDirection.TopLeft &&
this._actionWidget.direction !== ActionDirection.TopRight) {
this.shiftWidgetsUp();
}
}
/**
* Determine if a widget is occupying a specific row and column
* @param column The columns to check if occupied
* @param row The row to check if occupied
* @param ignoreResizing Whether or not to ignore the widget currently being resized
*/
getWidgetsAtPosition(column, row, ignoreResizing = false) {
return this.getOccupiedSpaces()
.filter(space => space.column === column && space.row === row)
.filter(space => this._actionWidget && space.widget !== this._actionWidget.widget || !ignoreResizing)
.map(space => space.widget);
}
/**
* Update the placeholder visibility, position and size
*/
setPlaceholderBounds(visible, x, y, width, height) {
const placeholder = this.placeholder$.getValue();
placeholder.visible = visible;
placeholder.column = this.getPlaceholderColumn(x, width);
placeholder.row = this.getPlaceholderRow(y, height);
placeholder.columnSpan = this.getPlaceholderColumnSpan(width);
placeholder.rowSpan = this.getPlaceholderRowSpan(height);
// calculate the maximum number of rows
const rowCount = this.widgets.filter(widget => widget !== this._actionWidget.widget)
.reduce((previous, widget) => Math.max(widget.getRow() + widget.getRowSpan(), previous), 0);
// constrain maximum placeholder row
placeholder.row = Math.min(placeholder.row, rowCount);
placeholder.x = (placeholder.column * this.getColumnWidth()) + this.options.padding;
placeholder.y = (placeholder.row * this._rowHeight) + this.options.padding;
placeholder.width = (placeholder.columnSpan * this.getColumnWidth()) - (this.options.padding * 2);
placeholder.height = (placeholder.rowSpan * this._rowHeight) - (this.options.padding * 2);
// set the values of the widget to match the values of the placeholder - however do not render the changes
this._actionWidget.widget.setColumn(placeholder.column, false);
this._actionWidget.widget.setRow(placeholder.row, false);
this._actionWidget.widget.setColumnSpan(placeholder.columnSpan, false);
this._actionWidget.widget.setRowSpan(placeholder.rowSpan, false);
// update the placeholder
this.placeholder$.next(placeholder);
}
/**
* Get the placeholder column position
*/
getPlaceholderColumn(x, width) {
const column = this.getColumnFromPx(x, this._actionWidget.direction === ActionDirection.Move ? Rounding.RoundUpOverHalf : Rounding.RoundDown);
const columnSpan = Math.floor(width / this.getColumnWidth());
const upperLimit = this.getColumnCount() - columnSpan;
// if we arent dragging left then just return the column
if (this._actionWidget.direction !== ActionDirection.Left &&
this._actionWidget.direction !== ActionDirection.TopLeft &&
this._actionWidget.direction !== ActionDirection.BottomLeft) {
return Math.max(Math.min(column, upperLimit), 0);
}
// get any overflow
const overflow = width % this.getColumnWidth();
return (x <= 0 || overflow === 0 || columnSpan === 0 || overflow > (this.getColumnWidth() / 2)) ?
Math.max(Math.min(column, upperLimit), 0) :
Math.max(Math.min(column + 1, upperLimit), 0);
}
/**
* Get the column span of the placeholder
*/
getPlaceholderColumnSpan(width) {
const columnSpan = this.getColumnFromPx(width);
// if we arent dragging right or left then just return the column span
if (this._actionWidget.direction !== ActionDirection.Right &&
this._actionWidget.direction !== ActionDirection.TopRight &&
this._actionWidget.direction !== ActionDirection.BottomRight &&
this._actionWidget.direction !== ActionDirection.Left &&
this._actionWidget.direction !== ActionDirection.TopLeft &&
this._actionWidget.direction !== ActionDirection.BottomLeft) {
return this.getColumnFromPx(width, Rounding.RoundUpOverHalf);
}
// get the current column span and any overflow
const overflow = width % this.getColumnWidth();
return (columnSpan > 0 && overflow > (this.getColumnWidth() / 2)) ? Math.max(columnSpan + 1, 1) : Math.max(columnSpan, 1);
}
/**
* Get the row position of the placeholder
*/
getPlaceholderRow(y, height) {
const row = this.getRowFromPx(y, this._actionWidget.direction === ActionDirection.Move ? Rounding.RoundUpOverHalf : Rounding.RoundDown);
const rowSpan = Math.ceil(height / this._rowHeight);
// if we arent dragging up then just return the row
if (this._actionWidget.direction !== ActionDirection.Top &&
this._actionWidget.direction !== ActionDirection.TopLeft &&
this._actionWidget.direction !== ActionDirection.TopRight) {
return Math.max(row, 0);
}
// get any overflow
const overflow = height < this._rowHeight ? 0 : height % this._rowHeight;
return (y <= 0 || rowSpan === 0 || overflow === 0 || overflow > (this._rowHeight / 2)) ? Math.max(row, 0) : Math.max(row + 1, 0);
}
/**
* Get the row span of the placeholder
*/
getPlaceholderRowSpan(height) {
const rowSpan = this.getRowFromPx(height);
// if we arent dragging up or down then just return the column span
if (this._actionWidget.direction !== ActionDirection.Top &&
this._actionWidget.direction !== ActionDirection.TopLeft &&
this._actionWidget.direction !== ActionDirection.TopRight &&
this._actionWidget.direction !== ActionDirection.Bottom &&
this._actionWidget.direction !== ActionDirection.BottomLeft &&
this._actionWidget.direction !== ActionDirection.BottomRight) {
return Math.max(rowSpan, 1);
}
// get the current column span and any overflow
const overflow = height % this._rowHeight;
return (overflow > (this._rowHeight / 2)) ? Math.max(rowSpan + 1, 1) : Math.max(rowSpan, 1);
}
getColumnFromPx(x, rounding = Rounding.RoundDown) {
const column = Math.floor(x / Math.floor(this.getColumnWidth()));
const overflow = (x % Math.floor(this.getColumnWidth()));
const half = this.getColumnWidth() / 2;
switch (rounding) {
case Rounding.RoundDown:
return column;
case Rounding.RoundDownBelowHalf:
return overflow < half ? column : column + 1;
case Rounding.RoundUpOverHalf:
return overflow > half ? column + 1 : column;
case Rounding.RoundUp:
return overflow > 0 ? column + 1 : column;
}
}
getRowFromPx(y, rounding = Rounding.RoundDown) {
const row = Math.floor(y / Math.floor(this._rowHeight));
const overflow = (y % Math.floor(this._rowHeight));
const half = this._rowHeight / 2;
switch (rounding) {
case Rounding.RoundDown:
return row;
case Rounding.RoundDownBelowHalf:
return overflow < half ? row : row + 1;
case Rounding.RoundUpOverHalf:
return overflow > half ? row + 1 : row;
case Rounding.RoundUp:
return overflow > 0 ? row + 1 : row;
}
}
commitWidgetChanges() {
const placeholder = this.placeholder$.getValue();
// check that we have all the values we need
if (placeholder.column === undefined || placeholder.row === undefined ||
placeholder.columnSpan === undefined || placeholder.rowSpan === undefined) {
return;
}
if (this._actionWidget) {
this._actionWidget.widget.setColumn(placeholder.column);
this._actionWidget.widget.setRow(placeholder.row);
this._actionWidget.widget.setColumnSpan(placeholder.columnSpan);
this._actionWidget.widget.setRowSpan(placeholder.rowSpan);
}
// reset all placeholder values
placeholder.column = undefined;
placeholder.row = undefined;
placeholder.columnSpan = undefined;
placeholder.rowSpan = undefined;
// emit the new placeholder values
this.placeholder$.next(placeholder);
}
/**
* Get the current column width
*/
getColumnWidth() {
return Math.floor(this.columnWidth);
}
/**
* Calculate the number of rows populated with widgets
*/
getRowCount() {
return this.widgets.reduce((previous, widget) => Math.max(widget.getRow() + widget.getRowSpan(), previous), 0);
}
/**
* Set the height of the dashboard container element
*/
setDashboardHeight() {
// size the dashboard container to ensure all rows fit
let rowCount = this.getRowCount();
// if we should show an empty row increment the row count by 1
if (this.options.emptyRow) {
rowCount++;
}
this.setDimensions(undefined, rowCount * this._rowHeight);
}
/**
* Orders the z-index of all widgets to move the active one to the front
* @param widget The widget that should be brought to the front
*/
bringToFront(widget) {
this.widgets.forEach(_widget => _widget === widget ? _widget.bringToFront() : _widget.sendToBack());
}
/**
* Move a widget down - if widgets are in the position below, then move them down further
* @param widget The widget to move downwards
*/
moveWidgetDown(widget, distance = 1) {
// stop if a negative number is passed through
if (distance < 0) {
return;
}
// move the widget down one position
widget.setRow((widget.getRow(DashboardStackMode.Auto)) + distance);
// check every space the widget occupies for collisions
this.forEachBlock(widget, (column, row) => this.getWidgetsAtPosition(column, row, true)
.filter(wgt => wgt !== widget)
.forEach(wgt => this.moveWidgetDown(wgt, distance)));
}
/**
* Widgets should not be allowed to have a vacant space above them - if there is one they should move upwards to fill it
*/
shiftWidgetsUp() {
// check whether or not changes have been made - if so we need to repeat until stable
let stable = true;
// iterate each widget and
this.widgets.forEach(widget => {
const widgetIsOnTopRow = widget.getRow() === 0;
const widgetIsBeingResized = this._actionWidget?.widget === widget;
const widgetShouldBeAutoPositioned = widget.autoPositioning || this.stacked;
const widgetIsBeingMoved = !widgetShouldBeAutoPositioned && this.isDragging$.value?.id === widget.id;
if (widgetIsOnTopRow || widgetIsBeingResized || widgetIsBeingMoved || (!widgetShouldBeAutoPositioned && !this._cache)) {
return;
}
if (!widgetShouldBeAutoPositioned) {
const cachedVersionOfWidget = this._cache.find(cachedWidget => cachedWidget.id === widget.id);
const isPreviousPositionAvailable = this.getPositionAvailable(cachedVersionOfWidget.column, cachedVersionOfWidget.row, cachedVersionOfWidget.columnSpan, cachedVersionOfWidget.rowSpan, widget);
if (isPreviousPositionAvailable && widget.row !== cachedVersionOfWidget.row) {
widget.setRow(cachedVersionOfWidget.row);
stable = false;
}
return;
}
if (this.getPositionAvailable(widget.getColumn(), widget.getRow() - 1, widget.getColumnSpan(), 1)) {
widget.setRow(widget.getRow() - 1);
stable = false;
}
});
// if changes occurred then we should repeat the process
if (!stable) {
this.shiftWidgetsUp();
return true;
}
return false;
}
/**
* Iterate over each space a widget occupied
* @param widget The widget to determine spaces
* @param callback The function to be called for each space, should expect a column and row argument witht he context being the widget
*/
forEachBlock(widget, callback) {
for (let row = widget.getRow(); row < widget.getRow() + widget.getRowSpan(); row++) {
for (let column = widget.getColumn(); column < widget.getColumn() + widget.getColumnSpan(); column++) {
callback.call(widget, column, row);
}
}
}
getWidgetBelow(widget) {
const target = this.getWidgetsAtPosition(widget.getColumn(), widget.getRow() + widget.getRowSpan(), true);
return target.length > 0 ? target[0] : null;
}
/**
* Returns the number of columns available
*/
getColumnCount() {
return this.stacked ? 1 : this.options.columns;
}
onShiftStart(widget) {
this.onDragStart({ direction: ActionDirection.Move, widget });
}
/** Programmatically move a widget in a given direction */
onShift(widget, direction) {
// get the current mouse position
let deltaX = 0, deltaY = 0;
// move based on the direction
switch (direction) {
case ActionDirection.Top:
deltaY = -this.getRowHeight();
break;
case ActionDirection.Right:
deltaX = this.getColumnWidth();
break;
case ActionDirection.Bottom: {
deltaY = this.getRowHeight();
break;
}
case ActionDirection.Left:
deltaX = -this.getColumnWidth();
break;
}
const dimensions = {
x: widget.x + deltaX,
y: widget.y + deltaY,
width: widget.width,
height: widget.height
};
// update placeholder position and value
this.setPlaceholderBounds(false, dimensions.x, dimensions.y, dimensions.width, dimensions.height);
// update widget position
const { x, y } = this.placeholder$.value;
// move the widget to the placeholder position
widget.setBounds(x - this.options.padding, y - this.options.padding, dimensions.width, dimensions.height);
// update the height of the dashboard
this.setDashboardHeight();
}
onShiftEnd() {
// show the widget positions if the current positions and sizes were to persist
this.shiftWidgets();
// the height of the dashboard may have changed after moving widgets
this.setDashboardHeight();
// reset all properties
this.onDragEnd();
}
/** Programmatically resize a widget in a given direction */
onResize(widget, direction) {
// perform the resizing
let deltaX = 0, deltaY = 0;
// move based on the direction
switch (direction) {
case ActionDirection.Top:
deltaY = -this.getRowHeight();
break;
case ActionDirection.Right:
deltaX = this.getColumnWidth();
break;
case ActionDirection.Bottom:
deltaY = this.getRowHeight();
break;
case ActionDirection.Left:
deltaX = -this.getColumnWidth();
break;
}
const dimensions = {
x: widget.x,
y: widget.y,
width: widget.width + deltaX,
height: widget.height + deltaY
};
const currentWidth = widget.x + widget.width;
const currentHeight = widget.y + widget.height;
// ensure values are within the dashboard bounds
if (dimensions.x < 0) {
dimensions.x = 0;
dimensions.width = currentWidth;
}
if (dimensions.y < 0) {
dimensions.y = 0;
dimensions.height = currentHeight;
}
if ((dimensions.x + dimensions.width) > this.getColumnWidth() * this.getColumnCount()) {
dimensions.width = widget.width;
}
// if the proposed width is smaller than allowed then reset width to minimum and ignore x changes
if (dimensions.width < this.getColumnWidth()) {
dimensions.x = widget.x;
dimensions.width = this.getColumnWidth();
}
// if the proposed height is smaller than allowed then reset height to minimum and ignore y changes
if (dimensions.height < this.getRowHeight()) {
dimensions.y = widget.y;
dimensions.height = this.getRowHeight();
}
const minWidth = this.getColumnWidth() * widget.minColSpan;
const isBelowMinWidth = dimensions.width < minWidth;
if (isBelowMinWidth) {
dimensions.x = widget.x;
dimensions.width = minWidth;
}
const minHeight = this.options.rowHeight * widget.minRowSpan;
const isBelowMinHeight = dimensions.height < minHeight;
if (isBelowMinHeight) {
dimensions.y = widget.y;
dimensions.height = minHeight;
}
// move the widget to the placeholder position
widget.setBounds(dimensions.x, dimensions.y, dimensions.width, dimensions.height);
// update placeholder position and value
this.setPlaceholderBounds(false, dimensions.x, dimensions.y, dimensions.width, dimensions.height);
// the height of the dashboard may have changed after moving widgets
this.setDashboardHeight();
}
getSur