@eclipse-scout/core
Version:
Eclipse Scout runtime
1,327 lines (1,156 loc) • 65.8 kB
text/typescript
/*
* Copyright (c) 2010, 2025 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {
arrays, CellModel, comparators, ContextMenuPopup, DateFormat, DateRange, dates, EnumObject, Event, graphics, HtmlComponent, InitModelOf, JsonDateRange, KeyStrokeContext, Menu, MenuBar, menus as menuUtil, objects, PlannerEventMap,
PlannerHeader, PlannerHeaderDisplayModeClickEvent, PlannerLayout, PlannerMenuItemsOrder, PlannerModel, Range, scout, scrollbars, strings, styles, tooltips, TooltipSupport, Widget, YearPanel, YearPanelDateSelectEvent
} from '../index';
import $ from 'jquery';
export class Planner extends Widget implements PlannerModel {
declare model: PlannerModel;
declare eventMap: PlannerEventMap;
declare self: Planner;
activityMap: Record<string, PlannerActivity>;
activitySelectable: boolean;
availableDisplayModes: PlannerDisplayMode[];
displayMode: PlannerDisplayMode;
displayModeOptions: Record<PlannerDisplayMode, PlannerDisplayModeOptions>;
headerVisible: boolean;
label: string;
resources: PlannerResource[];
resourceMap: Record<string, PlannerResource>;
multiSelect: boolean;
rangeSelectable: boolean;
selectionRange: DateRange;
selectedResources: PlannerResource[];
viewRange: DateRange;
startRange: DateRange;
lastRange: DateRange;
beginScale: number;
endScale: number;
selectedActivity: PlannerActivity;
menus: Menu[];
startRow: PlannerResource;
lastRow: PlannerResource;
menuBar: MenuBar;
header: PlannerHeader;
yearPanel: YearPanel;
defaultMenuTypes: string[];
/** scale calculator */
transformLeft: (t: number) => number;
transformWidth: (t0: number, t1: number) => number;
yearPanelVisible: boolean;
$range: JQuery;
$modes: JQuery;
$selectors: JQuery[];
$grid: JQuery;
$highlight: JQuery;
$timeline: JQuery;
$timelineLarge: JQuery;
$timelineSmall: JQuery;
$scaleTitle: JQuery;
$scale: JQuery;
protected _resourceTitleWidth: number;
protected _rangeSelectionStarted: boolean;
protected _resourceSelectionMode: PlannerResourceSelectionMode;
protected _tooltipSupport: TooltipSupport;
protected _$body: JQuery<Body>;
protected _gridScrollHandler: (event: JQuery.ScrollEvent) => void;
protected _cellMousemoveHandler: (event: JQuery.MouseMoveEvent<Document>) => void;
protected _resizeMousemoveHandler: (event: JQuery.MouseMoveEvent<Document>) => void;
protected _resourceTitleMousemoveHandler: (event: JQuery.MouseMoveEvent<Document>) => void;
constructor() {
super();
this.activityMap = {};
this.activitySelectable = false;
this.availableDisplayModes = [];
this.displayMode = null;
// @ts-expect-error
this.displayModeOptions = {};
this.headerVisible = true;
this.multiSelect = true;
this.label = null;
this.resources = [];
this.resourceMap = {};
this.rangeSelectable = true;
this.selectionRange = new DateRange();
this.selectedResources = [];
this.viewRange = new DateRange();
this.selectedActivity = null;
this.startRow = null;
this.lastRow = null;
this.defaultMenuTypes = [Planner.MenuType.EmptySpace];
this._resourceTitleWidth = 20;
this._rangeSelectionStarted = false;
this._resourceSelectionMode = Planner.ResourceSelectionMode.DEFAULT;
// main elements
this.$container = null;
this.$range = null;
this.$modes = null;
this.$grid = null;
this.transformLeft = t => t;
this.transformWidth = (t0, t1) => (t1 - t0);
this.yearPanelVisible = false;
this._addWidgetProperties(['menus']);
}
static Direction = {
BACKWARD: -1,
FORWARD: 1
} as const;
/**
* Enum providing display-modes for planner (extends calendar).
* @see IPlannerDisplayMode.java
*/
static DisplayMode = {
DAY: 1,
WEEK: 2,
MONTH: 3,
WORK_WEEK: 4,
CALENDAR_WEEK: 5,
YEAR: 6
} as const;
static RANGE_SELECTION_MOVE_THRESHOLD = 10;
static MenuType = {
Activity: 'Planner.Activity',
EmptySpace: 'Planner.EmptySpace',
Range: 'Planner.Range',
Resource: 'Planner.Resource'
} as const;
static ResourceSelectionMode = {
DEFAULT: 0,
DESELECT: 1,
ADD: 2
} as const;
protected override _createKeyStrokeContext(): KeyStrokeContext {
return new KeyStrokeContext();
}
protected override _init(model: InitModelOf<this>) {
super._init(model);
this.yearPanel = scout.create(YearPanel, {
parent: this,
alwaysSelectFirstDay: true
});
this.yearPanel.on('dateSelect', this._onYearPanelDateSelect.bind(this));
this.header = scout.create(PlannerHeader, {
parent: this
});
this.header.on('todayClick', this._onTodayClick.bind(this));
this.header.on('yearClick', this._onYearClick.bind(this));
this.header.on('previousClick', this._onPreviousClick.bind(this));
this.header.on('nextClick', this._onNextClick.bind(this));
this.header.on('displayModeClick', this._onDisplayModeClick.bind(this));
this.menuBar = scout.create(MenuBar, {
parent: this,
position: MenuBar.Position.BOTTOM,
menuOrder: new PlannerMenuItemsOrder(this.session, 'Planner', this.defaultMenuTypes),
cssClass: 'bounded'
});
for (let i = 0; i < this.resources.length; i++) {
this._initResource(this.resources[i]);
}
this._setDisplayMode(this.displayMode);
this._setAvailableDisplayModes(this.availableDisplayModes);
this._setViewRange(this.viewRange);
this._setSelectedResources(this.selectedResources);
this._setSelectedActivity(this.selectedActivity);
this._setSelectionRange(this.selectionRange);
this._setMenus(this.menus);
this._setDisplayModeOptions(this.displayModeOptions);
this._tooltipSupport = new TooltipSupport({
parent: this,
arrowPosition: 50
});
}
protected _initResource(resource: PlannerResourceModel): PlannerResource {
resource.activities.forEach(activity => this._initActivity(activity));
let res = resource as unknown as PlannerResource;
this.resourceMap[resource.id] = res;
return res;
}
protected _initActivity(activityModel: PlannerActivityModel) {
let activity = activityModel as unknown as PlannerActivity;
if (typeof activityModel.beginTime === 'string') {
activity.beginTime = dates.parseJsonDate(activityModel.beginTime);
}
if (typeof activityModel.endTime === 'string') {
activity.endTime = dates.parseJsonDate(activityModel.endTime);
}
this.activityMap[activity.id] = activity;
}
protected override _render() {
// basics, layout etc.
this.$container = this.$parent.appendDiv('planner');
let layout = new PlannerLayout(this);
this.htmlComp = HtmlComponent.install(this.$container, this.session);
this.htmlComp.setLayout(layout);
this._$body = this.$container.body();
// main elements
this.header.render();
this.yearPanel.render();
this.$grid = this.$container.appendDiv('planner-grid')
.on('mousedown', '.resource-cells', this._onCellMouseDown.bind(this))
.on('mousedown', '.resource-title', this._onResourceTitleMouseDown.bind(this))
.on('contextmenu', '.resource-title', this._onResourceTitleContextMenu.bind(this))
.on('contextmenu', '.planner-activity', this._onActivityContextMenu.bind(this));
this.$scale = this.$container.appendDiv('planner-scale');
this.menuBar.render();
tooltips.install(this.$grid, {
parent: this,
selector: '.planner-activity',
text: function($comp: JQuery) {
let activity = this.activityById($comp.attr('data-id'));
if (activity) {
return activity.tooltipText;
}
return undefined;
}.bind(this)
});
this._installScrollbars();
this._gridScrollHandler = this._onGridScroll.bind(this);
this.$grid.on('scroll', this._gridScrollHandler);
}
protected override _renderProperties() {
super._renderProperties();
this._renderViewRange();
this._renderHeaderVisible();
this._renderYearPanelVisible(false);
this._renderResources();
this._renderSelectedActivity();
this._renderSelectedResources();
// render with setTimeout because the planner needs to be layouted first
setTimeout(this._renderSelectionRange.bind(this));
}
protected override _remove() {
this._removeMouseMoveHandlers();
super._remove();
}
override get$Scrollable(): JQuery {
return this.$grid;
}
/* -- basics, events -------------------------------------------- */
protected _onPreviousClick(event: Event<PlannerHeader>) {
this._navigateDate(Planner.Direction.BACKWARD);
}
protected _onNextClick(event: Event<PlannerHeader>) {
this._navigateDate(Planner.Direction.FORWARD);
}
protected _navigateDate(direction: PlannerDirection) {
let viewRange = new DateRange(this.viewRange.from, this.viewRange.to);
let displayMode = Planner.DisplayMode;
let daysDiff = dates.compareDays(this.viewRange.to, this.viewRange.from);
if (this.displayMode === displayMode.DAY) {
viewRange.from = dates.shift(this.viewRange.from, 0, 0, direction);
viewRange.to = dates.shift(this.viewRange.to, 0, 0, direction);
} else if (scout.isOneOf(this.displayMode, displayMode.WEEK, displayMode.WORK_WEEK)) {
viewRange.from = dates.shift(this.viewRange.from, 0, 0, direction * 7);
viewRange.from = dates.ensureMonday(viewRange.from, -1 * direction);
viewRange.to = dates.shift(viewRange.from, 0, 0, daysDiff);
} else if (scout.isOneOf(this.displayMode, displayMode.MONTH, displayMode.CALENDAR_WEEK)) {
viewRange.from = dates.shift(this.viewRange.from, 0, direction, 0);
viewRange.from = dates.ensureMonday(viewRange.from, -1 * direction);
viewRange.to = dates.shift(viewRange.from, 0, 0, daysDiff);
} else if (this.displayMode === displayMode.YEAR) {
viewRange.from = dates.shift(this.viewRange.from, 0, 3 * direction, 0);
viewRange.to = dates.shift(this.viewRange.to, 0, 3 * direction, 0);
}
this.setViewRange(viewRange);
}
protected _onTodayClick(event: Event<PlannerHeader>) {
let today = this._today(),
year = today.getFullYear(),
month = today.getMonth(),
date = today.getDate(),
day = (today.getDay() + 6) % 7,
displayMode = Planner.DisplayMode;
if (this.displayMode === displayMode.DAY) {
today = new Date(year, month, date);
} else if (this.displayMode === displayMode.YEAR) {
today = new Date(year, month, 1);
} else {
today = new Date(year, month, date - day); // set day to Monday
}
this.setViewRangeFrom(today);
}
protected _today(): Date {
return new Date();
}
protected _onDisplayModeClick(event: PlannerHeaderDisplayModeClickEvent) {
let displayMode = event.displayMode;
this.setDisplayMode(displayMode);
}
protected _onYearClick(event: Event<PlannerHeader>) {
this.setYearPanelVisible(!this.yearPanelVisible);
}
protected _onYearPanelDateSelect(event: YearPanelDateSelectEvent) {
this.setViewRangeFrom(event.date);
}
protected _onResourceTitleMouseDown(event: JQuery.MouseDownEvent) {
let $resource = $(event.target).parent();
let selectedResource = $resource.data('resource') as PlannerResource;
if ($resource.isSelected()) {
if (event.which === 3) {
// Right click on an already selected resource must not clear the selection -> context menu will be opened
return;
}
}
if (event.which === 1 && event.ctrlKey) {
if ($resource.isSelected()) {
this._resourceSelectionMode = Planner.ResourceSelectionMode.DESELECT;
} else if (this.multiSelect) {
this._resourceSelectionMode = Planner.ResourceSelectionMode.ADD;
}
}
this.startRow = selectedResource;
this.lastRow = this.startRow;
this._select();
// add event handlers
this._removeMouseMoveHandlers();
if (this.multiSelect) {
this._resourceTitleMousemoveHandler = this._onResourceTitleMousemove.bind(this);
this._$body.document()
.on('mousemove', this._resourceTitleMousemoveHandler)
.one('mouseup', this._onDocumentMouseUp.bind(this));
}
}
protected _onResourceTitleMousemove(event: JQuery.MouseMoveEvent<Document>) {
let lastRow = this._findRow(event.pageY);
if (lastRow) {
this.lastRow = lastRow;
}
this._select();
}
protected _onResourceTitleContextMenu(event: JQuery.ContextMenuEvent) {
this._showContextMenu(event, Planner.MenuType.Resource);
}
protected _onRangeSelectorContextMenu(event: JQuery.ContextMenuEvent) {
this._showContextMenu(event, Planner.MenuType.Range);
}
protected _onActivityContextMenu(event: JQuery.ContextMenuEvent) {
this._showContextMenu(event, Planner.MenuType.Activity);
}
protected _showContextMenu(event: JQuery.ContextMenuEvent, allowedType: string) {
event.preventDefault();
event.stopPropagation();
let func = function func(event: JQuery.ContextMenuEvent, allowedType: string) {
if (!this.rendered || !this.attached) { // check needed because function is called asynchronously
return;
}
let filteredMenus: Menu[] = this._filterMenus([allowedType], true);
let $part = $(event.currentTarget);
if (filteredMenus.length === 0) {
return; // at least one menu item must be visible
}
let popup = scout.create(ContextMenuPopup, {
parent: this,
menuItems: filteredMenus,
location: {
x: event.pageX,
y: event.pageY
},
$anchor: $part
});
popup.open();
}.bind(this);
this.session.onRequestsDone(func, event, allowedType);
}
protected _onGridScroll(event: JQuery.ScrollEvent) {
this._reconcileScrollPos();
}
/**
* @internal
*/
_reconcileScrollPos() {
// When scrolling horizontally scroll scale as well
let scrollLeft = this.$grid.scrollLeft();
this.$scale.scrollLeft(scrollLeft);
}
protected _renderRange() {
if (!this.viewRange.from || !this.viewRange.to) {
return;
}
let text: string,
toDate = new Date(this.viewRange.to.valueOf() - 1),
toText = this.session.text('ui.to'),
displayMode = Planner.DisplayMode;
// find range text
if (dates.isSameDay(this.viewRange.from, toDate)) {
text = this._dateFormat(this.viewRange.from, 'd. MMMM yyyy');
} else if (this.viewRange.from.getMonth() === toDate.getMonth() && this.viewRange.from.getFullYear() === toDate.getFullYear()) {
text = strings.join(' ', this._dateFormat(this.viewRange.from, 'd.'), toText, this._dateFormat(toDate, 'd. MMMM yyyy'));
} else if (this.viewRange.from.getFullYear() === toDate.getFullYear()) {
if (this.displayMode === displayMode.YEAR) {
text = strings.join(' ', this._dateFormat(this.viewRange.from, 'MMMM'), toText, this._dateFormat(toDate, 'MMMM yyyy'));
} else {
text = strings.join(' ', this._dateFormat(this.viewRange.from, 'd. MMMM'), toText, this._dateFormat(toDate, 'd. MMMM yyyy'));
}
} else {
if (this.displayMode === displayMode.YEAR) {
text = strings.join(' ', this._dateFormat(this.viewRange.from, 'MMMM yyyy'), toText, this._dateFormat(toDate, 'MMMM yyyy'));
} else {
text = strings.join(' ', this._dateFormat(this.viewRange.from, 'd. MMMM yyyy'), toText, this._dateFormat(toDate, 'd. MMMM yyyy'));
}
}
// set text
$('.planner-select', this.header.$range).text(text);
}
protected _renderScale() {
if (!this.viewRange.from || !this.viewRange.to || !this.displayMode) {
return;
}
let that = this,
displayMode = Planner.DisplayMode;
// empty scale
this.$scale.empty();
this.$grid.children('.planner-small-scale-item-line').remove();
this.$grid.children('.planner-large-scale-item-line').remove();
// append main elements
this.$scaleTitle = this.$scale.appendDiv('planner-scale-title');
this._renderLabel();
this.$timeline = this.$scale.appendDiv('timeline');
this.$timelineLarge = this.$timeline.appendDiv('timeline-large');
this.$timelineSmall = this.$timeline.appendDiv('timeline-small');
// fill timeline large depending on mode
if (this.displayMode === displayMode.DAY) {
this._renderDayScale();
} else if (scout.isOneOf(this.displayMode, displayMode.WORK_WEEK, displayMode.WEEK)) {
this._renderWeekScale();
} else if (this.displayMode === displayMode.MONTH) {
this._renderMonthScale();
} else if (this.displayMode === displayMode.CALENDAR_WEEK) {
this._renderCalendarWeekScale();
} else if (this.displayMode === displayMode.YEAR) {
this._renderYearScale();
}
// set sizes and append scale lines
let $smallScaleItems = this.$timelineSmall.children('.scale-item');
let $largeScaleItems = this.$timelineLarge.children('.scale-item');
let width = 100 / $smallScaleItems.length;
$largeScaleItems.each(function() {
let $scaleItem = $(this);
let $largeGridLine = that.$grid.prependDiv('planner-large-scale-item-line');
$scaleItem.css('width', $scaleItem.data('count') * width + '%')
.data('scale-item-line', $largeGridLine);
$scaleItem.prependDiv('planner-large-scale-item-line')
.css('left', 0);
});
$smallScaleItems.each(function(index) {
let $scaleItem = $(this);
$scaleItem.css('width', width + '%');
if (!$scaleItem.data('first')) {
let $smallGridLine = that.$grid.prependDiv('planner-small-scale-item-line');
$scaleItem.data('scale-item-line', $smallGridLine);
$scaleItem.prependDiv('planner-small-scale-item-line')
.css('left', 0);
}
});
// find transfer function
let dateFrom = this.$timelineSmall.children().first().data('date-from') as Date;
this.beginScale = dateFrom.valueOf();
let dateTo = this.$timelineSmall.children().last().data('date-to') as Date;
this.endScale = dateTo.valueOf();
if (scout.isOneOf(this.displayMode, displayMode.WORK_WEEK, displayMode.WEEK)) {
let options = this.displayModeOptions[this.displayMode];
let interval = options.interval;
let firstHourOfDay = options.firstHourOfDay;
let lastHourOfDay = options.lastHourOfDay;
this.transformLeft = ((begin: Date, end: Date, firstHour: number, lastHour: number, interval: number) => t => {
let newDate = new Date(t);
begin = new Date(begin);
end = new Date(end);
let fullRangeMillis = end.valueOf() - begin.valueOf();
// remove day component from range for scaling
let dayDiffTBegin = dates.compareDays(newDate, begin);
let dayDIffEndBegin = dates.compareDays(end, begin);
let dayComponentMillis = dayDiffTBegin * 3600000 * 24;
let rangeScaling = (24 / (lastHour - firstHour + 1));
// re-add day component
let dayOffset = dayDiffTBegin / dayDIffEndBegin;
if (newDate.getHours() < firstHour) {
// If newDate is in the morning before the first hour of day, return 00:00 of this day
return (rangeScaling / fullRangeMillis + dayOffset) * 100;
}
if (newDate.getHours() > lastHour) {
// If newDate is in the evening after the last hour of day, return 00:00 of next day
dayOffset = (dayDiffTBegin + 1) / dayDIffEndBegin;
return (rangeScaling / fullRangeMillis + dayOffset) * 100;
}
return ((newDate.valueOf() - (begin.valueOf() + firstHour * 3600000) - dayComponentMillis) * rangeScaling / fullRangeMillis + dayOffset) * 100;
})(this.viewRange.from, this.viewRange.to, firstHourOfDay, lastHourOfDay, interval);
this.transformWidth = ((begin: Date, end: Date, firstHour: number, lastHour: number, interval: number) => (function(t0, t1) {
let fullRangeMillis = end.valueOf() - begin.valueOf();
let selectionRange = new Range(t0, t1);
let hiddenRanges = this._findHiddenRangesInWeekMode();
let selectedRangeMillis = selectionRange.subtractAll(hiddenRanges)
.reduce((acc, range) => acc + range.size(), 0);
let rangeScaling = (24 / (lastHour - firstHour + 1));
return (selectedRangeMillis * rangeScaling) / fullRangeMillis * 100;
}))(this.viewRange.from, this.viewRange.to, firstHourOfDay, lastHourOfDay, interval);
} else {
this.transformLeft = ((begin, end) => t => (t - begin) / (end - begin) * 100)(this.beginScale, this.endScale);
this.transformWidth = ((begin, end) => (t0, t1) => (t1 - t0) / (end - begin) * 100)(this.beginScale, this.endScale);
}
}
/**
* Returns every hidden range of the view range created by first and last our of day.
*/
protected _findHiddenRangesInWeekMode(): Range[] {
if (!scout.isOneOf(this.displayMode, Planner.DisplayMode.WORK_WEEK, Planner.DisplayMode.WEEK)) {
return [];
}
let ranges: Range[] = [];
let options = this.displayModeOptions[this.displayMode];
let firstHourOfDay = options.firstHourOfDay;
let lastHourOfDay = options.lastHourOfDay;
let currentDate = new Date(this.viewRange.from.valueOf());
while (currentDate < this.viewRange.to) {
// Start of day range
let hiddenRange = new Range(new Date(currentDate.valueOf()).valueOf(), dates.shiftTime(currentDate, firstHourOfDay).valueOf());
if (hiddenRange.size() > 0) {
ranges.push(hiddenRange);
}
// End of day range
hiddenRange = new Range(dates.shiftTime(currentDate, lastHourOfDay + 1).valueOf(), dates.shiftTime(currentDate, 24).valueOf());
if (hiddenRange.size() > 0) {
ranges.push(hiddenRange);
}
currentDate.setHours(0);
currentDate.setMinutes(0);
currentDate.setDate(currentDate.getDate() + 1);
}
return ranges;
}
protected _renderDayScale() {
let newLargeGroup: boolean, $divLarge: JQuery, $divSmall: JQuery, first = true;
let loop = new Date(this.viewRange.from.valueOf());
let options = this.displayModeOptions[this.displayMode];
let interval = options.interval;
let labelPeriod = options.labelPeriod;
let firstHourOfDay = options.firstHourOfDay;
let lastHourOfDay = options.lastHourOfDay;
// cap interval to day range
interval = Math.min(interval, 60 * (lastHourOfDay - firstHourOfDay + 1));
// from start to end
while (loop < this.viewRange.to) {
if (loop.getHours() >= firstHourOfDay && loop.getHours() <= lastHourOfDay) {
newLargeGroup = false;
if (loop.getMinutes() === 0 || first) {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'HH')).data('count', 0);
newLargeGroup = true;
}
$divSmall = this.$timelineSmall
.appendDiv('scale-item', this._dateFormat(loop, ':mm'))
.data('date-from', new Date(loop.valueOf()));
// hide label
if ((loop.getMinutes() % (interval * labelPeriod)) !== 0) {
$divSmall.addClass('label-invisible');
}
// increase variables
loop = dates.shiftTime(loop, 0, interval, 0);
this._incrementTimelineScaleItems($divLarge, $divSmall, loop, newLargeGroup);
first = false;
} else {
loop = dates.shiftTime(loop, 0, interval, 0);
}
}
}
protected _renderWeekScale() {
let newLargeGroup: boolean, $divLarge: JQuery, $divSmall: JQuery, first = true;
let loop = new Date(this.viewRange.from.valueOf());
let options = this.displayModeOptions[this.displayMode];
let interval = options.interval;
let labelPeriod = options.labelPeriod;
let firstHourOfDay = options.firstHourOfDay;
let lastHourOfDay = options.lastHourOfDay;
// cap interval to day range
interval = Math.min(interval, 60 * (lastHourOfDay - firstHourOfDay + 1));
// from start to end
while (loop < this.viewRange.to) {
newLargeGroup = false;
if (loop.getHours() < firstHourOfDay) {
loop.setHours(firstHourOfDay);
}
if (loop.getHours() === firstHourOfDay && loop.getMinutes() === 0 || first) {
if (loop.getMonth() === 0 || first) {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'd. MMMM yyyy')).data('count', 0);
} else if (loop.getDate() === 1) {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'd. MMMM')).data('count', 0);
} else {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'd.')).data('count', 0);
}
newLargeGroup = true;
}
$divSmall = this.$timelineSmall
.appendDiv('scale-item', this._dateFormat(loop, 'HH:mm'))
.data('date-from', new Date(loop.valueOf()));
// hide label
if (((loop.getHours() - firstHourOfDay) * 60 + loop.getMinutes()) % (interval * labelPeriod) !== 0) {
$divSmall.addClass('label-invisible');
}
// increase variables
loop = dates.shiftTime(loop, 0, interval, 0, 0);
this._incrementTimelineScaleItems($divLarge, $divSmall, loop, newLargeGroup);
first = false;
if (loop.getHours() > lastHourOfDay) {
// jump to next day
loop.setHours(0);
loop.setMinutes(0);
loop.setDate(loop.getDate() + 1);
}
}
}
protected _renderMonthScale() {
let newLargeGroup: boolean, $divLarge: JQuery, $divSmall: JQuery, first = true;
let loop = new Date(this.viewRange.from.valueOf());
let options = this.displayModeOptions[this.displayMode];
let labelPeriod = options.labelPeriod;
// from start to end
while (loop < this.viewRange.to) {
newLargeGroup = false;
if (loop.getDate() === 1 || first) {
if (loop.getMonth() === 0 || first) {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'MMMM yyyy')).data('count', 0);
} else {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'MMMM')).data('count', 0);
}
newLargeGroup = true;
}
$divSmall = this.$timelineSmall
.appendDiv('scale-item', this._dateFormat(loop, 'dd'))
.data('date-from', new Date(loop.valueOf()));
// hide label
if (loop.getDate() % labelPeriod !== 0) {
$divSmall.addClass('label-invisible');
}
loop = dates.shift(loop, 0, 0, 1);
this._incrementTimelineScaleItems($divLarge, $divSmall, loop, newLargeGroup);
first = false;
}
}
protected _renderCalendarWeekScale() {
let newLargeGroup: boolean, $divLarge: JQuery, $divSmall: JQuery, first: boolean | number = true;
let loop = new Date(this.viewRange.from.valueOf());
let options = this.displayModeOptions[this.displayMode];
let labelPeriod = options.labelPeriod;
// from start to end
while (loop < this.viewRange.to) {
newLargeGroup = false;
if (loop.getDate() < 8 || first === true) {
if (loop.getMonth() === 0 || first === true) {
if (loop.getDate() > 11) {
$divLarge = this.$timelineLarge.appendDiv('scale-item').html(' ').data('count', 0);
first = 2;
} else {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'MMMM yyyy')).data('count', 0);
first = false;
}
} else {
if (first === 2) {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'MMMM yyyy')).data('count', 0);
first = false;
} else {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'MMMM')).data('count', 0);
}
}
newLargeGroup = true;
}
$divSmall = this.$timelineSmall
.appendDiv('scale-item', dates.weekInYear(loop) + '')
.data('date-from', new Date(loop.valueOf()))
.data('tooltipText', this._scaleTooltipText.bind(this));
this._tooltipSupport.install($divSmall);
// hide label
if (dates.weekInYear(loop) % labelPeriod !== 0) {
$divSmall.addClass('label-invisible');
}
loop.setDate(loop.getDate() + 7);
this._incrementTimelineScaleItems($divLarge, $divSmall, loop, newLargeGroup);
}
}
protected _renderYearScale() {
let newLargeGroup: boolean, $divLarge: JQuery, $divSmall: JQuery, first = true;
let loop = new Date(this.viewRange.from.valueOf());
let options = this.displayModeOptions[this.displayMode];
let labelPeriod = options.labelPeriod;
// from start to end
while (loop < this.viewRange.to) {
newLargeGroup = false;
if (loop.getMonth() === 0 || first) {
$divLarge = this.$timelineLarge.appendDiv('scale-item', this._dateFormat(loop, 'yyyy')).data('count', 0);
newLargeGroup = true;
}
$divSmall = this.$timelineSmall
.appendDiv('scale-item', this._dateFormat(loop, 'MMMM'))
.data('date-from', new Date(loop.valueOf()));
// hide label
if (loop.getMonth() % labelPeriod !== 0) {
$divSmall.addClass('label-invisible');
}
loop = dates.shift(loop, 0, 1, 0);
this._incrementTimelineScaleItems($divLarge, $divSmall, loop, newLargeGroup);
first = false;
}
}
protected _incrementTimelineScaleItems($largeComp: JQuery, $smallComp: JQuery, newDate: Date, newLargeGroup: boolean) {
$largeComp.data('count', $largeComp.data('count') + 1);
$smallComp.data('date-to', new Date(newDate.valueOf()))
.data('first', newLargeGroup);
}
/* -- scale events --------------------------------------------------- */
protected _scaleTooltipText($scale: JQuery): string {
let toText = ' ' + this.session.text('ui.to') + ' ',
from = new Date($scale.data('date-from').valueOf()),
to = new Date($scale.data('date-to').valueOf() - 1);
if (from.getMonth() === to.getMonth()) {
return this._dateFormat(from, 'd.') + toText + this._dateFormat(to, 'd. MMMM yyyy');
} else if (from.getFullYear() === to.getFullYear()) {
return this._dateFormat(from, 'd. MMMM') + toText + this._dateFormat(to, 'd. MMMM yyyy');
}
return this._dateFormat(from, 'd. MMMM yyyy') + toText + this._dateFormat(to, 'd. MMMM yyyy');
}
/* -- render resources, activities --------------------------------- */
protected _removeAllResources() {
this.resources.forEach(resource => resource.$resource.remove());
}
protected _renderResources(resources?: PlannerResource[]) {
let resource: PlannerResource, resourcesHtml = '';
resources = resources || this.resources;
for (let i = 0; i < resources.length; i++) {
resource = resources[i];
resourcesHtml += this._buildResourceHtml(resource);
}
// Append resources to grid
$(resourcesHtml).appendTo(this.$grid);
// Match resources
this.$grid.children('.planner-resource').each((index, element) => {
let $element = $(element);
resource = this.resourceById($element.attr('data-id'));
this._linkResource($element, resource);
this._linkActivitiesForResource(resource);
});
}
protected _linkResource($resource: JQuery, resource: PlannerResource) {
$resource.data('resource', resource);
resource.$resource = $resource;
resource.$cells = $resource.children('.resource-cells');
}
protected _linkActivity($activity: JQuery, activity: PlannerActivity) {
$activity.data('activity', activity);
activity.$activity = $activity;
}
protected _rerenderActivities(resources?: PlannerResource[]) {
resources = resources || this.resources;
resources.forEach(resource => {
this._removeActivitiesForResource(resource);
this._renderActivitiesForResource(resource);
});
}
protected _buildResourceHtml(resource: PlannerResource): string {
let resourceHtml = '<div class="planner-resource" data-id="' + resource.id + '">';
resourceHtml += '<div class="resource-title">' + strings.encode(resource.resourceCell.text || '') + '</div>';
resourceHtml += '<div class="resource-cells">' + this._buildActivitiesHtml(resource) + '</div>';
resourceHtml += '</div>';
return resourceHtml;
}
protected _renderActivitiesForResource(resource: PlannerResource) {
resource.$cells.html(this._buildActivitiesHtml(resource));
this._linkActivitiesForResource(resource);
}
protected _linkActivitiesForResource(resource: PlannerResource) {
resource.$cells.children('.planner-activity').each((index, element) => {
let $element = $(element);
let activity = this.activityById($element.attr('data-id'));
this._linkActivity($element, activity);
});
}
protected _buildActivitiesHtml(resource: PlannerResource): string {
let activitiesHtml = '';
resource.activities.forEach(activity => {
if (activity.beginTime.valueOf() >= this.endScale || activity.endTime.valueOf() <= this.beginScale) {
// don't add activities which are not in the view range
return;
}
activitiesHtml += this._buildActivityHtml(activity);
});
return activitiesHtml;
}
protected _removeActivitiesForResource(resource: PlannerResource) {
resource.activities.forEach(activity => {
if (activity.$activity) {
activity.$activity.remove();
activity.$activity = null;
}
});
}
protected _buildActivityHtml(activity: PlannerActivity): string {
let level = 100 - Math.min(activity.level * 100, 100),
backgroundColor = styles.modelToCssColor(activity.backgroundColor),
foregroundColor = styles.modelToCssColor(activity.foregroundColor),
levelColor = styles.modelToCssColor(activity.levelColor),
beginTime = activity.beginTime,
endTime = activity.endTime,
begin = beginTime.valueOf(),
end = endTime.valueOf();
// Make sure activity fits into scale
begin = Math.max(begin, this.beginScale);
end = Math.min(end, this.endScale);
let activityCssClass = 'planner-activity' + (activity.cssClass ? (' ' + activity.cssClass) : '');
let activityStyle = 'left: ' + 'calc(' + this.transformLeft(begin) + '% + 2px);';
activityStyle += ' width: ' + 'calc(' + this.transformWidth(begin, end) + '% - 4px);';
if (levelColor) {
activityStyle += ' background-color: ' + levelColor + ';';
activityStyle += ' border-color: ' + levelColor + ';';
}
if (!levelColor && backgroundColor) {
activityStyle += ' background-color: ' + backgroundColor + ';';
activityStyle += ' border-color: ' + backgroundColor + ';';
}
if (foregroundColor) {
activityStyle += ' color: ' + foregroundColor + ';';
}
// The background-color represents the fill level and not the image. This makes it easier to change the color using a css class
// In order to change the background rather than the fill, use the planner-activity-level css class
let activityLevelCssClass = 'planner-activity-level' + (activity.cssClass ? (' ' + activity.cssClass) : '');
let activityEmptyColor = styles.get(activityLevelCssClass, 'background-color').backgroundColor;
activityStyle += ' background-image: ' + 'linear-gradient(to bottom, ' + activityEmptyColor + ' 0%, ' + activityEmptyColor + ' ' + level + '%, transparent ' + level + '%, transparent 100% );';
let activityHtml = '<div';
activityHtml += ' class="' + activityCssClass + '"';
activityHtml += ' style="' + activityStyle + '"';
activityHtml += ' data-id="' + activity.id + '"';
activityHtml += '>' + strings.encode(activity.text || '') + '</div>';
return activityHtml;
}
/* -- selector -------------------------------------------------- */
protected _onCellMouseDown(event: JQuery.MouseDownEvent) {
let $activity,
$resource,
$target = $(event.target),
opensContextMenu = (event.which === 3 || event.which === 1 && event.ctrlKey);
if (this.activitySelectable) {
if (!opensContextMenu && this.$selectors) {
// Hide selector otherwise activity may not be resolved (elementFromPoint would return the $selector)
// This allows selecting an activity which is inside a selection range
this.$selectors.forEach(s => s.hide());
}
$activity = this.$grid.elementFromPoint(event.pageX, event.pageY);
if (!opensContextMenu && this.$selectors) {
this.$selectors.forEach(s => s.show());
}
if ($activity.hasClass('planner-activity')) {
$resource = $activity.parent().parent();
this.selectResources([$resource.data('resource')]);
this.selectActivity($activity.data('activity'));
this.selectRange(new DateRange());
} else {
this.selectActivity(null);
}
} else {
this.selectActivity(null);
}
if (!this.rangeSelectable) {
return;
}
if ($target.hasClass('selector')) {
if (opensContextMenu) {
// Right click on the selector must not clear the selection -> context menu will be opened
return;
}
} else if (event.which === 1 && event.ctrlKey && this.multiSelect) {
this._resourceSelectionMode = Planner.ResourceSelectionMode.ADD;
}
if (!this.selectedActivity) {
// If not an activity was selected, start immediately, otherwise start as soon the mouse moves
this._startRangeSelection(event.pageX, event.pageY);
}
// add event handlers
this._removeMouseMoveHandlers();
this._cellMousemoveHandler = this._onCellMousemove.bind(this, event);
this._$body.document()
.on('mousemove', this._cellMousemoveHandler)
.one('mouseup', this._onDocumentMouseUp.bind(this));
}
protected _startRangeSelection(pageX: number, pageY: number) {
// init selector
this.startRow = this._findRow(pageY);
this.lastRow = this.startRow;
// find range on scale
this.startRange = this._findScale(pageX);
this.lastRange = this.startRange;
// draw
this._select();
this._rangeSelectionStarted = true;
}
/**
* @returns true if the range selection may be started, false if not
*/
protected _prepareRangeSelectionByMousemove(mousedownEvent: JQuery.MouseDownEvent, mousemoveEvent: JQuery.MouseMoveEvent<Document>): boolean {
let moveX = mousedownEvent.pageX - mousemoveEvent.pageX;
let moveY = mousedownEvent.pageY - mousemoveEvent.pageY;
let moveThreshold = Planner.RANGE_SELECTION_MOVE_THRESHOLD;
if (Math.abs(moveX) >= moveThreshold) {
// Accept if x movement is big enough
return true;
}
let mousedownRow = this._findRow(mousedownEvent.pageY);
let mousemoveRow = this._findRow(mousemoveEvent.pageY);
// Accept if y movement is big enough AND the row changed. No need to switch into range selection mode if cursor is still on the same row
return Math.abs(moveY) >= moveThreshold && this.multiSelect && mousedownRow !== mousemoveRow;
}
protected _onCellMousemove(mousedownEvent: JQuery.MouseDownEvent, event: JQuery.MouseMoveEvent<Document>) {
if (this.selectedActivity && !this._rangeSelectionStarted) {
// If an activity was selected, switch to range selection if the user moves the mouse
if (!this._prepareRangeSelectionByMousemove(mousedownEvent, event)) {
return;
}
this._startRangeSelection(mousedownEvent.pageX, mousedownEvent.pageY);
}
let lastRow = this._findRow(event.pageY);
if (lastRow) {
this.lastRow = lastRow;
}
let lastRange = this._findScale(event.pageX);
if (lastRange) {
this.lastRange = lastRange;
}
this._select();
}
protected _onResizeMouseDown(event: JQuery.MouseDownEvent): boolean {
let swap: DateRange,
$target = $(event.target);
this.startRow = this.selectedResources[0];
this.lastRow = this.selectedResources[this.selectedResources.length - 1];
// Get the start and last range based on the clicked resize handle. If the ranges cannot be determined it likely means that the selectionRange is out of the current viewRange or dayRange (set by firstHourOfDay, lastHourOfDay)
this.startRange = scout.nvl(this._findScaleByFromDate(this.selectionRange.from), new Range(this.selectionRange.from.getTime(), this.selectionRange.from.getTime()));
this.lastRange = scout.nvl(this._findScaleByToDate(this.selectionRange.to), new Range(this.selectionRange.to.getTime(), this.selectionRange.to.getTime()));
// Swap start and last range if resize-left is clicked
if ($target.hasClass('selector-resize-left')) {
swap = this.startRange;
this.startRange = this.lastRange;
this.lastRange = swap;
}
this._$body.addClass('col-resize');
this._removeMouseMoveHandlers();
this._resizeMousemoveHandler = this._onResizeMousemove.bind(this);
this._$body.document()
.on('mousemove', this._resizeMousemoveHandler)
.one('mouseup', this._onDocumentMouseUp.bind(this));
return false;
}
protected _onResizeMousemove(event: JQuery.MouseMoveEvent) {
if (!this.rendered) {
// planner may be removed in the meantime
return;
}
let lastRange = this._findScale(event.pageX);
if (lastRange) {
this.lastRange = lastRange;
}
this._select(false);
}
protected _onDocumentMouseUp(event: JQuery.MouseUpEvent<Document>) {
this._$body.removeClass('col-resize');
this._removeMouseMoveHandlers();
this._resourceSelectionMode = Planner.ResourceSelectionMode.DEFAULT;
if (!this._rangeSelectionStarted) {
// Range selection has not been initiated -> don't call select()
return;
}
this._rangeSelectionStarted = false;
if (this.rendered) {
this._select(false);
}
}
protected _removeMouseMoveHandlers() {
if (this._cellMousemoveHandler) {
this._$body.document().off('mousemove', this._cellMousemoveHandler);
this._cellMousemoveHandler = null;
}
if (this._resourceTitleMousemoveHandler) {
this._$body.document().off('mousemove', this._resourceTitleMousemoveHandler);
this._resourceTitleMousemoveHandler = null;
}
if (this._resizeMousemoveHandler) {
this._$body.document().off('mousemove', this._resizeMousemoveHandler);
this._resizeMousemoveHandler = null;
}
}
protected _select(updateResources = true) {
if (updateResources && (!this.startRow || !this.lastRow)) {
return;
}
let rangeSelected = !!(this.startRange && this.lastRange);
if (updateResources) {
let $startRow = this.startRow.$resource,
$lastRow = this.lastRow.$resource;
// in case of single selection
if (!this.multiSelect) {
this.lastRow = this.startRow;
$lastRow = this.startRow.$resource;
}
// select rows
let $upperRow = ($startRow[0].offsetTop <= $lastRow[0].offsetTop) ? $startRow : $lastRow,
$lowerRow = ($startRow[0].offsetTop > $lastRow[0].offsetTop) ? $startRow : $lastRow,
resources = $('.planner-resource', this.$grid).toArray(),
top = $upperRow[0].offsetTop,
low = $lowerRow[0].offsetTop;
for (let r = resources.length - 1; r >= 0; r--) {
let row = resources[r];
if ((row.offsetTop < top && row.offsetTop < low) || (row.offsetTop > top && row.offsetTop > low)) {
resources.splice(r, 1);
}
}
let selectedResources = resources.map(i => $(i).data('resource'));
if (this._resourceSelectionMode === Planner.ResourceSelectionMode.ADD) {
selectedResources = selectedResources.concat(this.selectedResources).sort((a, b) => comparators.NUMERIC.compare(a.$resource.index(), b.$resource.index()));
this.selectResources(selectedResources);
} else if (this._resourceSelectionMode === Planner.ResourceSelectionMode.DESELECT) {
this.deselectResources(selectedResources);
} else {
this.selectResources(selectedResources);
}
}
this.selectActivity(null);
if (rangeSelected) {
// left and width
let from = Math.min(this.lastRange.from.valueOf(), this.startRange.from.valueOf()),
to = Math.max(this.lastRange.to.valueOf(), this.startRange.to.valueOf());
let selectionRange = new DateRange(new Date(from), new Date(to));
selectionRange = this._adjustSelectionRange(selectionRange);
this.selectRange(selectionRange);
}
}
protected _adjustSelectionRange(range: DateRange): DateRange {
let from = range.from.getTime();
let to = range.to.getTime();
// Ensure minimum size of selection range (interval is in minutes)
let minSelectionDuration = 0;
let options = this.displayModeOptions[this.displayMode];
if (options.interval > 0 && options.minSelectionIntervalCount > 0) {
minSelectionDuration = options.minSelectionIntervalCount * options.interval * 60000;
}
let lastHourOfDay = options.lastHourOfDay;
let endOfDay = dates.shiftTime(dates.trunc(range.from), lastHourOfDay + 1);
let viewRange = this._visibleViewRange();
if (this.lastRange.from < this.startRange.from) {
// Selecting to left
if (to - minSelectionDuration >= viewRange.from.getTime()) {
// extend to left side
from = Math.min(from, to - minSelectionDuration);
} else {
// extend to right side if from would be smaller than the minimum date (left border)
from = viewRange.from.getTime();
to = Math.max(to, Math.min(from + minSelectionDuration, viewRange.to.getTime()));
}
} else {
// Selecting to right
if (from + minSelectionDuration <= viewRange.to.getTime()) {
// extend to right side
to = Math.max(to, Math.max(from + minSelectionDuration, viewRange.from.getTime()));
if (to >= endOfDay.getTime() && new Range(from, to).size() === minSelectionDuration) {
// extend to left side when clicking at the end of a day
to = endOfDay.getTime();
from = Math.min(from, to - minSelectionDuration);
}
} else {
// extend to left side if to would be greater than the maximum date (right border)
to = viewRange.to.getTime();
from = Math.min(from, to - minSelectionDuration);
}
}
return new DateRange(new Date(from), new Date(to));
}
protected _findRow(y: number): PlannerResource {
let $row,
gridBounds = graphics.offsetBounds(this.$grid),
x = gridBounds.x + 10;
y = Math.min(Math.max(y, 0), gridBounds.y + gridBounds.height - 1);
$row = this.$container.elementFromPoint(x, y, '.planner-resource');
if ($row.length > 0) {
return $row.data('resource');
}
return null;
}
protected _findScale(x: number): DateRange {
let $scaleItem,
gridBounds = graphics.offsetBounds(this.$grid),
y = this.$scale.offset().top + this.$scale.height() * 0.75;
x = Math.min(Math.max(x, 0), gridBounds.x + gridBounds.width - 1);
$scaleItem = this.$container.elementFromPoint(x, y, '.scale-item');
if ($scaleItem.length > 0) {
return new DateRange($scaleItem.data('date-from').valueOf(), $scaleItem.data('date-to').valueOf());
}
return null;
}
protected _findScaleByFromDate(from: Date): DateRange {
return this._findScaleByFunction((i, elem) => {
let $scaleItem = $(elem);
if ($scaleItem.data('date-from').getTime() === from.getTime()) {
return true;
}
});
}
protected _findScaleByToDate(to: Date): DateRange {
return this._findScaleByFunction((i, elem) => {
let $scaleItem = $(elem);
if ($scaleItem.data('date-to').getTime() === to.getTime()) {
return true;
}
});
}
protected _findScaleByFunction(func: (index: number, element: HTMLElement) => boolean): DateRange {
let $scaleItem = this.$timelineSmall.children('.scale-item').filter(func);
if (!$scaleItem.length) {
return null;
}
return new DateRange($scaleItem.data('date-from').valueOf(), $scaleItem.data('date-to').valueOf());
}
/**
* @returns the visible view range (the difference to this.viewRange is that first and last hourOfDay are considered).
*/
protected _visibleViewRange(): DateRange {
let $items = this.$timelineSmall.children('.scale-item');
return new DateRange($items.first().data('date-from'), $items.last().data('date-to'));
}
/* -- helper ---------------------------------------------------- */
protected _dateFormat(date: Date, pattern: string): string {
let d = new Date(date.valueOf()),
dateFormat = new DateFormat(this.session.locale, pattern);
return dateFormat.format(d);
}
protected _renderViewRange() {
this._renderRange();
this._renderScale();
this.invalidateLayoutTree();
}
protected _renderHeaderVisible() {
this.header.setVisible(this.headerVisible);
this.invalidateLayoutTree();
}
protected _renderYearPanelVisible(animated: boolean) {
let yearPanelWidth;
if (this.yearPanelVisible) {
this.yearPanel.renderContent();
}
// show or hide year panel
$('.calendar-toggle-year', this.$modes).select(this.yearPanelVisible);
if (this.yearPanelVisible) {
yearPanelWidth = 210;
} else {
yearPanelWidth = 0;
}
this.yearPanel.$container.animate({
width: yearPanelWidth
}, {
duration: animated ? 500 : 0,
progress: this._onYearPanelWidthChange.bind(this),
complete: this._afterYearPanelWidthChange.bind(this)
});
}
protected _onYearPanelWidthChange() {
if (!this.yearPanel.$container) {
// If container has been removed in the meantime (e.g. user navigates away while ani