UNPKG

@ux-aspects/ux-aspects

Version:

Open source user interface framework for building modern, responsive, mobile big data applications

1,094 lines 187 kB
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