mirror of
https://github.com/nasa/openmct.git
synced 2025-05-22 10:13:58 +00:00
[Plugins] Bring over timeline, clock plugins
WTD-1239
This commit is contained in:
parent
8c1b70f085
commit
c932e953bc
@ -11,11 +11,13 @@
|
|||||||
"platform/containment",
|
"platform/containment",
|
||||||
"platform/execution",
|
"platform/execution",
|
||||||
"platform/telemetry",
|
"platform/telemetry",
|
||||||
|
"platform/features/clock",
|
||||||
"platform/features/imagery",
|
"platform/features/imagery",
|
||||||
"platform/features/layout",
|
"platform/features/layout",
|
||||||
"platform/features/pages",
|
"platform/features/pages",
|
||||||
"platform/features/plot",
|
"platform/features/plot",
|
||||||
"platform/features/scrolling",
|
"platform/features/scrolling",
|
||||||
|
"platform/features/timeline",
|
||||||
"platform/features/events",
|
"platform/features/events",
|
||||||
"platform/forms",
|
"platform/forms",
|
||||||
"platform/identity",
|
"platform/identity",
|
||||||
|
173
platform/features/clock/bundle.json
Normal file
173
platform/features/clock/bundle.json
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
{
|
||||||
|
"name": "WARP Clocks/Timers",
|
||||||
|
"descriptions": "Domain objects for displaying current & relative times.",
|
||||||
|
"configuration": {
|
||||||
|
"paths": {
|
||||||
|
"moment-duration-format": "moment-duration-format"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"constants": [
|
||||||
|
{
|
||||||
|
"key": "CLOCK_INDICATOR_FORMAT",
|
||||||
|
"value": "YYYY/MM/DD HH:mm:ss"
|
||||||
|
}
|
||||||
|
|
||||||
|
],
|
||||||
|
"indicators": [
|
||||||
|
{
|
||||||
|
"implementation": "indicators/ClockIndicator.js",
|
||||||
|
"depends": [ "warp.tickerService", "CLOCK_INDICATOR_FORMAT" ],
|
||||||
|
"priority": "preferred"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"key": "warp.tickerService",
|
||||||
|
"implementation": "services/TickerService.js",
|
||||||
|
"depends": [ "$timeout", "now" ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"controllers": [
|
||||||
|
{
|
||||||
|
"key": "ClockController",
|
||||||
|
"implementation": "controllers/ClockController.js",
|
||||||
|
"depends": [ "$scope", "warp.tickerService" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "TimerController",
|
||||||
|
"implementation": "controllers/TimerController.js",
|
||||||
|
"depends": [ "$scope", "$window", "now" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "RefreshingController",
|
||||||
|
"implementation": "controllers/RefreshingController.js",
|
||||||
|
"depends": [ "$scope", "warp.tickerService" ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"key": "warp.clock",
|
||||||
|
"type": "warp.clock",
|
||||||
|
"templateUrl": "templates/clock.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warp.timer",
|
||||||
|
"type": "warp.timer",
|
||||||
|
"templateUrl": "templates/timer.html"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"key": "warp.timer.start",
|
||||||
|
"implementation": "actions/StartTimerAction.js",
|
||||||
|
"depends": ["now"],
|
||||||
|
"category": "contextual",
|
||||||
|
"name": "Start",
|
||||||
|
"glyph": "\u00EF",
|
||||||
|
"priority": "preferred"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warp.timer.restart",
|
||||||
|
"implementation": "actions/RestartTimerAction.js",
|
||||||
|
"depends": ["now"],
|
||||||
|
"category": "contextual",
|
||||||
|
"name": "Restart at 0",
|
||||||
|
"glyph": "r",
|
||||||
|
"priority": "preferred"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"key": "warp.clock",
|
||||||
|
"name": "Clock",
|
||||||
|
"glyph": "C",
|
||||||
|
"features": [ "creation" ],
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"key": "clockFormat",
|
||||||
|
"name": "Display Format",
|
||||||
|
"control": "composite",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"control": "select",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "YYYY/MM/DD hh:mm:ss",
|
||||||
|
"name": "YYYY/MM/DD hh:mm:ss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "YYYY/DDD hh:mm:ss",
|
||||||
|
"name": "YYYY/DDD hh:mm:ss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "hh:mm:ss",
|
||||||
|
"name": "hh:mm:ss"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"control": "select",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "clock12",
|
||||||
|
"name": "12hr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "clock24",
|
||||||
|
"name": "24hr"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"model": {
|
||||||
|
"clockFormat": [ "YYYY/MM/DD hh:mm:ss", "clock12" ]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warp.timer",
|
||||||
|
"name": "Timer",
|
||||||
|
"glyph": "\u00F5",
|
||||||
|
"features": [ "creation" ],
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"key": "timestamp",
|
||||||
|
"control": "datetime",
|
||||||
|
"name": "Target"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "timerFormat",
|
||||||
|
"control": "select",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "long",
|
||||||
|
"name": "DDD hh:mm:ss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "short",
|
||||||
|
"name": "hh:mm:ss"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"model": {
|
||||||
|
"timerFormat": "DDD hh:mm:ss"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"licenses": [
|
||||||
|
{
|
||||||
|
"name": "moment-duration-format",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"author": "John Madhavan-Reese",
|
||||||
|
"description": "Duration parsing/formatting",
|
||||||
|
"website": "https://github.com/jsmreese/moment-duration-format",
|
||||||
|
"copyright": "Copyright 2014 John Madhavan-Reese",
|
||||||
|
"license": "license-mit",
|
||||||
|
"link": "https://github.com/jsmreese/moment-duration-format/blob/master/LICENSE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
482
platform/features/clock/lib/moment-duration-format.js
Normal file
482
platform/features/clock/lib/moment-duration-format.js
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
/*! Moment Duration Format v1.3.0
|
||||||
|
* https://github.com/jsmreese/moment-duration-format
|
||||||
|
* Date: 2014-07-15
|
||||||
|
*
|
||||||
|
* Duration format plugin function for the Moment.js library
|
||||||
|
* http://momentjs.com/
|
||||||
|
*
|
||||||
|
* Copyright 2014 John Madhavan-Reese
|
||||||
|
* Released under the MIT license
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function (root, undefined) {
|
||||||
|
|
||||||
|
// repeatZero(qty)
|
||||||
|
// returns "0" repeated qty times
|
||||||
|
function repeatZero(qty) {
|
||||||
|
var result = "";
|
||||||
|
|
||||||
|
// exit early
|
||||||
|
// if qty is 0 or a negative number
|
||||||
|
// or doesn't coerce to an integer
|
||||||
|
qty = parseInt(qty, 10);
|
||||||
|
if (!qty || qty < 1) { return result; }
|
||||||
|
|
||||||
|
while (qty) {
|
||||||
|
result += "0";
|
||||||
|
qty -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// padZero(str, len [, isRight])
|
||||||
|
// pads a string with zeros up to a specified length
|
||||||
|
// will not pad a string if its length is aready
|
||||||
|
// greater than or equal to the specified length
|
||||||
|
// default output pads with zeros on the left
|
||||||
|
// set isRight to `true` to pad with zeros on the right
|
||||||
|
function padZero(str, len, isRight) {
|
||||||
|
if (str == null) { str = ""; }
|
||||||
|
str = "" + str;
|
||||||
|
|
||||||
|
return (isRight ? str : "") + repeatZero(len - str.length) + (isRight ? "" : str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// isArray
|
||||||
|
function isArray(array) {
|
||||||
|
return Object.prototype.toString.call(array) === "[object Array]";
|
||||||
|
}
|
||||||
|
|
||||||
|
// isObject
|
||||||
|
function isObject(obj) {
|
||||||
|
return Object.prototype.toString.call(obj) === "[object Object]";
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLast
|
||||||
|
function findLast(array, callback) {
|
||||||
|
var index = array.length;
|
||||||
|
|
||||||
|
while (index -= 1) {
|
||||||
|
if (callback(array[index])) { return array[index]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// find
|
||||||
|
function find(array, callback) {
|
||||||
|
var index = 0,
|
||||||
|
max = array.length,
|
||||||
|
match;
|
||||||
|
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
match = callback;
|
||||||
|
callback = function (item) {
|
||||||
|
return item === match;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
while (index < max) {
|
||||||
|
if (callback(array[index])) { return array[index]; }
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// each
|
||||||
|
function each(array, callback) {
|
||||||
|
var index = 0,
|
||||||
|
max = array.length;
|
||||||
|
|
||||||
|
if (!array || !max) { return; }
|
||||||
|
|
||||||
|
while (index < max) {
|
||||||
|
if (callback(array[index], index) === false) { return; }
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// map
|
||||||
|
function map(array, callback) {
|
||||||
|
var index = 0,
|
||||||
|
max = array.length,
|
||||||
|
ret = [];
|
||||||
|
|
||||||
|
if (!array || !max) { return ret; }
|
||||||
|
|
||||||
|
while (index < max) {
|
||||||
|
ret[index] = callback(array[index], index);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluck
|
||||||
|
function pluck(array, prop) {
|
||||||
|
return map(array, function (item) {
|
||||||
|
return item[prop];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// compact
|
||||||
|
function compact(array) {
|
||||||
|
var ret = [];
|
||||||
|
|
||||||
|
each(array, function (item) {
|
||||||
|
if (item) { ret.push(item); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// unique
|
||||||
|
function unique(array) {
|
||||||
|
var ret = [];
|
||||||
|
|
||||||
|
each(array, function (_a) {
|
||||||
|
if (!find(ret, _a)) { ret.push(_a); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// intersection
|
||||||
|
function intersection(a, b) {
|
||||||
|
var ret = [];
|
||||||
|
|
||||||
|
each(a, function (_a) {
|
||||||
|
each(b, function (_b) {
|
||||||
|
if (_a === _b) { ret.push(_a); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return unique(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rest
|
||||||
|
function rest(array, callback) {
|
||||||
|
var ret = [];
|
||||||
|
|
||||||
|
each(array, function (item, index) {
|
||||||
|
if (!callback(item)) {
|
||||||
|
ret = array.slice(index);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial
|
||||||
|
function initial(array, callback) {
|
||||||
|
var reversed = array.slice().reverse();
|
||||||
|
|
||||||
|
return rest(reversed, callback).reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// extend
|
||||||
|
function extend(a, b) {
|
||||||
|
for (var key in b) {
|
||||||
|
if (b.hasOwnProperty(key)) { a[key] = b[key]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// define internal moment reference
|
||||||
|
var moment;
|
||||||
|
|
||||||
|
if (typeof require === "function") {
|
||||||
|
try { moment = require('moment'); }
|
||||||
|
catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moment && root.moment) {
|
||||||
|
moment = root.moment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moment) {
|
||||||
|
throw "Moment Duration Format cannot find Moment.js";
|
||||||
|
}
|
||||||
|
|
||||||
|
// moment.duration.format([template] [, precision] [, settings])
|
||||||
|
moment.duration.fn.format = function () {
|
||||||
|
|
||||||
|
var tokenizer, tokens, types, typeMap, momentTypes, foundFirst, trimIndex,
|
||||||
|
args = [].slice.call(arguments),
|
||||||
|
settings = extend({}, this.format.defaults),
|
||||||
|
// keep a shadow copy of this moment for calculating remainders
|
||||||
|
remainder = moment.duration(this);
|
||||||
|
|
||||||
|
// add a reference to this duration object to the settings for use
|
||||||
|
// in a template function
|
||||||
|
settings.duration = this;
|
||||||
|
|
||||||
|
// parse arguments
|
||||||
|
each(args, function (arg) {
|
||||||
|
if (typeof arg === "string" || typeof arg === "function") {
|
||||||
|
settings.template = arg;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof arg === "number") {
|
||||||
|
settings.precision = arg;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isObject(arg)) {
|
||||||
|
extend(settings, arg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// types
|
||||||
|
types = settings.types = (isArray(settings.types) ? settings.types : settings.types.split(" "));
|
||||||
|
|
||||||
|
// template
|
||||||
|
if (typeof settings.template === "function") {
|
||||||
|
settings.template = settings.template.apply(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenizer regexp
|
||||||
|
tokenizer = new RegExp(map(types, function (type) {
|
||||||
|
return settings[type].source;
|
||||||
|
}).join("|"), "g");
|
||||||
|
|
||||||
|
// token type map function
|
||||||
|
typeMap = function (token) {
|
||||||
|
return find(types, function (type) {
|
||||||
|
return settings[type].test(token);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// tokens array
|
||||||
|
tokens = map(settings.template.match(tokenizer), function (token, index) {
|
||||||
|
var type = typeMap(token),
|
||||||
|
length = token.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: index,
|
||||||
|
length: length,
|
||||||
|
|
||||||
|
// replace escaped tokens with the non-escaped token text
|
||||||
|
token: (type === "escape" ? token.replace(settings.escape, "$1") : token),
|
||||||
|
|
||||||
|
// ignore type on non-moment tokens
|
||||||
|
type: ((type === "escape" || type === "general") ? null : type)
|
||||||
|
|
||||||
|
// calculate base value for all moment tokens
|
||||||
|
//baseValue: ((type === "escape" || type === "general") ? null : this.as(type))
|
||||||
|
};
|
||||||
|
}, this);
|
||||||
|
|
||||||
|
// unique moment token types in the template (in order of descending magnitude)
|
||||||
|
momentTypes = intersection(types, unique(compact(pluck(tokens, "type"))));
|
||||||
|
|
||||||
|
// exit early if there are no momentTypes
|
||||||
|
if (!momentTypes.length) {
|
||||||
|
return pluck(tokens, "token").join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate values for each token type in the template
|
||||||
|
each(momentTypes, function (momentType, index) {
|
||||||
|
var value, wholeValue, decimalValue, isLeast, isMost;
|
||||||
|
|
||||||
|
// calculate integer and decimal value portions
|
||||||
|
value = remainder.as(momentType);
|
||||||
|
wholeValue = (value > 0 ? Math.floor(value) : Math.ceil(value));
|
||||||
|
decimalValue = value - wholeValue;
|
||||||
|
|
||||||
|
// is this the least-significant moment token found?
|
||||||
|
isLeast = ((index + 1) === momentTypes.length);
|
||||||
|
|
||||||
|
// is this the most-significant moment token found?
|
||||||
|
isMost = (!index);
|
||||||
|
|
||||||
|
// update tokens array
|
||||||
|
// using this algorithm to not assume anything about
|
||||||
|
// the order or frequency of any tokens
|
||||||
|
each(tokens, function (token) {
|
||||||
|
if (token.type === momentType) {
|
||||||
|
extend(token, {
|
||||||
|
value: value,
|
||||||
|
wholeValue: wholeValue,
|
||||||
|
decimalValue: decimalValue,
|
||||||
|
isLeast: isLeast,
|
||||||
|
isMost: isMost
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isMost) {
|
||||||
|
// note the length of the most-significant moment token:
|
||||||
|
// if it is greater than one and forceLength is not set, default forceLength to `true`
|
||||||
|
if (settings.forceLength == null && token.length > 1) {
|
||||||
|
settings.forceLength = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rationale is this:
|
||||||
|
// if the template is "h:mm:ss" and the moment value is 5 minutes, the user-friendly output is "5:00", not "05:00"
|
||||||
|
// shouldn't pad the `minutes` token even though it has length of two
|
||||||
|
// if the template is "hh:mm:ss", the user clearly wanted everything padded so we should output "05:00"
|
||||||
|
// if the user wanted the full padded output, they can set `{ trim: false }` to get "00:05:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// update remainder
|
||||||
|
remainder.subtract(wholeValue, momentType);
|
||||||
|
});
|
||||||
|
|
||||||
|
// trim tokens array
|
||||||
|
if (settings.trim) {
|
||||||
|
tokens = (settings.trim === "left" ? rest : initial)(tokens, function (token) {
|
||||||
|
// return `true` if:
|
||||||
|
// the token is not the least moment token (don't trim the least moment token)
|
||||||
|
// the token is a moment token that does not have a value (don't trim moment tokens that have a whole value)
|
||||||
|
return !(token.isLeast || (token.type != null && token.wholeValue));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// build output
|
||||||
|
|
||||||
|
// the first moment token can have special handling
|
||||||
|
foundFirst = false;
|
||||||
|
|
||||||
|
// run the map in reverse order if trimming from the right
|
||||||
|
if (settings.trim === "right") {
|
||||||
|
tokens.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = map(tokens, function (token) {
|
||||||
|
var val,
|
||||||
|
decVal;
|
||||||
|
|
||||||
|
if (!token.type) {
|
||||||
|
// if it is not a moment token, use the token as its own value
|
||||||
|
return token.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply negative precision formatting to the least-significant moment token
|
||||||
|
if (token.isLeast && (settings.precision < 0)) {
|
||||||
|
val = (Math.floor(token.wholeValue * Math.pow(10, settings.precision)) * Math.pow(10, -settings.precision)).toString();
|
||||||
|
} else {
|
||||||
|
val = token.wholeValue.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove negative sign from the beginning
|
||||||
|
val = val.replace(/^\-/, "");
|
||||||
|
|
||||||
|
// apply token length formatting
|
||||||
|
// special handling for the first moment token that is not the most significant in a trimmed template
|
||||||
|
if (token.length > 1 && (foundFirst || token.isMost || settings.forceLength)) {
|
||||||
|
val = padZero(val, token.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add decimal value if precision > 0
|
||||||
|
if (token.isLeast && (settings.precision > 0)) {
|
||||||
|
decVal = token.decimalValue.toString().replace(/^\-/, "").split(/\.|e\-/);
|
||||||
|
switch (decVal.length) {
|
||||||
|
case 1:
|
||||||
|
val += "." + padZero(decVal[0], settings.precision, true).slice(0, settings.precision);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
val += "." + padZero(decVal[1], settings.precision, true).slice(0, settings.precision);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
val += "." + padZero(repeatZero((+decVal[2]) - 1) + (decVal[0] || "0") + decVal[1], settings.precision, true).slice(0, settings.precision);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw "Moment Duration Format: unable to parse token decimal value.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add a negative sign if the value is negative and token is most significant
|
||||||
|
if (token.isMost && token.value < 0) {
|
||||||
|
val = "-" + val;
|
||||||
|
}
|
||||||
|
|
||||||
|
foundFirst = true;
|
||||||
|
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
|
// undo the reverse if trimming from the right
|
||||||
|
if (settings.trim === "right") {
|
||||||
|
tokens.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
moment.duration.fn.format.defaults = {
|
||||||
|
// token definitions
|
||||||
|
escape: /\[(.+?)\]/,
|
||||||
|
years: /[Yy]+/,
|
||||||
|
months: /M+/,
|
||||||
|
weeks: /[Ww]+/,
|
||||||
|
days: /[Dd]+/,
|
||||||
|
hours: /[Hh]+/,
|
||||||
|
minutes: /m+/,
|
||||||
|
seconds: /s+/,
|
||||||
|
milliseconds: /S+/,
|
||||||
|
general: /.+?/,
|
||||||
|
|
||||||
|
// token type names
|
||||||
|
// in order of descending magnitude
|
||||||
|
// can be a space-separated token name list or an array of token names
|
||||||
|
types: "escape years months weeks days hours minutes seconds milliseconds general",
|
||||||
|
|
||||||
|
// format options
|
||||||
|
|
||||||
|
// trim
|
||||||
|
// "left" - template tokens are trimmed from the left until the first moment token that has a value >= 1
|
||||||
|
// "right" - template tokens are trimmed from the right until the first moment token that has a value >= 1
|
||||||
|
// (the final moment token is not trimmed, regardless of value)
|
||||||
|
// `false` - template tokens are not trimmed
|
||||||
|
trim: "left",
|
||||||
|
|
||||||
|
// precision
|
||||||
|
// number of decimal digits to include after (to the right of) the decimal point (positive integer)
|
||||||
|
// or the number of digits to truncate to 0 before (to the left of) the decimal point (negative integer)
|
||||||
|
precision: 0,
|
||||||
|
|
||||||
|
// force first moment token with a value to render at full length even when template is trimmed and first moment token has length of 1
|
||||||
|
forceLength: null,
|
||||||
|
|
||||||
|
// template used to format duration
|
||||||
|
// may be a function or a string
|
||||||
|
// template functions are executed with the `this` binding of the settings object
|
||||||
|
// so that template strings may be dynamically generated based on the duration object
|
||||||
|
// (accessible via `this.duration`)
|
||||||
|
// or any of the other settings
|
||||||
|
template: function () {
|
||||||
|
var types = this.types,
|
||||||
|
dur = this.duration,
|
||||||
|
lastType = findLast(types, function (type) {
|
||||||
|
return dur._data[type];
|
||||||
|
});
|
||||||
|
|
||||||
|
// default template strings for each duration dimension type
|
||||||
|
switch (lastType) {
|
||||||
|
case "seconds":
|
||||||
|
return "h:mm:ss";
|
||||||
|
case "minutes":
|
||||||
|
return "d[d] h:mm";
|
||||||
|
case "hours":
|
||||||
|
return "d[d] h[h]";
|
||||||
|
case "days":
|
||||||
|
return "M[m] d[d]";
|
||||||
|
case "weeks":
|
||||||
|
return "y[y] w[w]";
|
||||||
|
case "months":
|
||||||
|
return "y[y] M[m]";
|
||||||
|
case "years":
|
||||||
|
return "y[y]";
|
||||||
|
default:
|
||||||
|
return "y[y] M[m] d[d] h:mm:ss";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
})(this);
|
13
platform/features/clock/res/templates/clock.html
Normal file
13
platform/features/clock/res/templates/clock.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<div class="l-time-display l-digital l-clock s-clock" ng-controller="ClockController as clock">
|
||||||
|
<div class="l-elem-wrapper">
|
||||||
|
<span class="l-elem timezone">
|
||||||
|
{{clock.zone()}}
|
||||||
|
</span>
|
||||||
|
<span class="l-elem value active">
|
||||||
|
{{clock.text()}}
|
||||||
|
</span>
|
||||||
|
<span class="l-elem ampm">
|
||||||
|
{{clock.ampm()}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
21
platform/features/clock/res/templates/timer.html
Normal file
21
platform/features/clock/res/templates/timer.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<div class="l-time-display l-digital l-timer s-timer" ng-controller="TimerController as timer">
|
||||||
|
<div class="l-elem-wrapper">
|
||||||
|
<a
|
||||||
|
ng-click="timer.clickButton()"
|
||||||
|
title="{{timer.buttonText()}}"
|
||||||
|
class="l-elem l-btn s-btn s-icon-btn s-very-subtle vsm control"
|
||||||
|
>
|
||||||
|
<span class="ui-symbol icon">{{timer.buttonGlyph()}}</span>
|
||||||
|
</a>
|
||||||
|
<span class="l-elem l-value">
|
||||||
|
<span class="ui-symbol direction">{{timer.sign()}}</span>
|
||||||
|
<span
|
||||||
|
class="value"
|
||||||
|
ng-class="{ active:timer.text() }"
|
||||||
|
>{{timer.text() || "--:--:--"}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span ng-controller="RefreshingController">
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,41 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the "Start" and "Restart" action for timers.
|
||||||
|
*
|
||||||
|
* Sets the reference timestamp in a timer to the current
|
||||||
|
* time, such that it begins counting up.
|
||||||
|
*
|
||||||
|
* Both "Start" and "Restart" share this implementation, but
|
||||||
|
* control their visibility with different `appliesTo` behavior.
|
||||||
|
*
|
||||||
|
* @implements Action
|
||||||
|
*/
|
||||||
|
function AbstractStartTimerAction(now, context) {
|
||||||
|
var domainObject = context.domainObject;
|
||||||
|
|
||||||
|
function doPersist() {
|
||||||
|
var persistence = domainObject.getCapability('persistence');
|
||||||
|
return persistence && persistence.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimestamp(model) {
|
||||||
|
model.timestamp = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
perform: function () {
|
||||||
|
return domainObject.useCapability('mutation', setTimestamp)
|
||||||
|
.then(doPersist);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return AbstractStartTimerAction;
|
||||||
|
}
|
||||||
|
);
|
33
platform/features/clock/src/actions/RestartTimerAction.js
Normal file
33
platform/features/clock/src/actions/RestartTimerAction.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./AbstractStartTimerAction'],
|
||||||
|
function (AbstractStartTimerAction) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the "Restart at 0" action.
|
||||||
|
*
|
||||||
|
* Behaves the same as (and delegates functionality to)
|
||||||
|
* the "Start" action.
|
||||||
|
* @implements Action
|
||||||
|
*/
|
||||||
|
function RestartTimerAction(now, context) {
|
||||||
|
return new AbstractStartTimerAction(now, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
RestartTimerAction.appliesTo = function (context) {
|
||||||
|
var model =
|
||||||
|
(context.domainObject && context.domainObject.getModel())
|
||||||
|
|| {};
|
||||||
|
|
||||||
|
// We show this variant for timers which already have
|
||||||
|
// a target time.
|
||||||
|
return model.type === 'warp.timer' &&
|
||||||
|
model.timestamp !== undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return RestartTimerAction;
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
34
platform/features/clock/src/actions/StartTimerAction.js
Normal file
34
platform/features/clock/src/actions/StartTimerAction.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./AbstractStartTimerAction'],
|
||||||
|
function (AbstractStartTimerAction) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the "Start" action for timers.
|
||||||
|
*
|
||||||
|
* Sets the reference timestamp in a timer to the current
|
||||||
|
* time, such that it begins counting up.
|
||||||
|
*
|
||||||
|
* @implements Action
|
||||||
|
*/
|
||||||
|
function StartTimerAction(now, context) {
|
||||||
|
return new AbstractStartTimerAction(now, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
StartTimerAction.appliesTo = function (context) {
|
||||||
|
var model =
|
||||||
|
(context.domainObject && context.domainObject.getModel())
|
||||||
|
|| {};
|
||||||
|
|
||||||
|
// We show this variant for timers which do not yet have
|
||||||
|
// a target time.
|
||||||
|
return model.type === 'warp.timer' &&
|
||||||
|
model.timestamp === undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return StartTimerAction;
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
79
platform/features/clock/src/controllers/ClockController.js
Normal file
79
platform/features/clock/src/controllers/ClockController.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['moment'],
|
||||||
|
function (moment) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for views of a Clock domain object.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function ClockController($scope, tickerService) {
|
||||||
|
var text,
|
||||||
|
ampm,
|
||||||
|
use24,
|
||||||
|
lastTimestamp,
|
||||||
|
unlisten,
|
||||||
|
timeFormat;
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
var m = moment.utc(lastTimestamp);
|
||||||
|
text = timeFormat && m.format(timeFormat);
|
||||||
|
ampm = m.format("A"); // Just the AM or PM part
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(timestamp) {
|
||||||
|
lastTimestamp = timestamp;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFormat(clockFormat) {
|
||||||
|
var baseFormat;
|
||||||
|
|
||||||
|
if (clockFormat !== undefined) {
|
||||||
|
baseFormat = clockFormat[0];
|
||||||
|
|
||||||
|
use24 = clockFormat[1] === 'clock24';
|
||||||
|
timeFormat = use24 ?
|
||||||
|
baseFormat.replace('hh', "HH") : baseFormat;
|
||||||
|
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pull in the clock format from the domain object model
|
||||||
|
$scope.$watch('model.clockFormat', updateFormat);
|
||||||
|
|
||||||
|
// Listen for clock ticks ... and stop listening on destroy
|
||||||
|
unlisten = tickerService.listen(tick);
|
||||||
|
$scope.$on('$destroy', unlisten);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the clock's time zone, as displayable text.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
zone: function () {
|
||||||
|
return "UTC";
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the current time, as displayable text.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
text: function () {
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the text to display to qualify a time as AM or PM.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
ampm: function () {
|
||||||
|
return use24 ? '' : ampm;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClockController;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,29 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continually refreshes the represented domain object.
|
||||||
|
*
|
||||||
|
* This is a short-term workaround to assure Timer views stay
|
||||||
|
* up-to-date; should be replaced by a global auto-refresh.
|
||||||
|
*/
|
||||||
|
function RefreshingController($scope, tickerService) {
|
||||||
|
var unlisten;
|
||||||
|
|
||||||
|
function triggerRefresh() {
|
||||||
|
var persistence = $scope.domainObject &&
|
||||||
|
$scope.domainObject.getCapability('persistence');
|
||||||
|
return persistence && persistence.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
unlisten = tickerService.listen(triggerRefresh);
|
||||||
|
$scope.$on('$destroy', unlisten);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshingController;
|
||||||
|
}
|
||||||
|
);
|
146
platform/features/clock/src/controllers/TimerController.js
Normal file
146
platform/features/clock/src/controllers/TimerController.js
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./TimerFormatter'],
|
||||||
|
function (TimerFormatter) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var FORMATTER = new TimerFormatter();
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for views of a Timer domain object.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimerController($scope, $window, now) {
|
||||||
|
var timerObject,
|
||||||
|
relevantAction,
|
||||||
|
sign = '',
|
||||||
|
text = '',
|
||||||
|
formatter,
|
||||||
|
active = true,
|
||||||
|
relativeTimestamp,
|
||||||
|
lastTimestamp;
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
var timeDelta = lastTimestamp - relativeTimestamp;
|
||||||
|
|
||||||
|
if (formatter && !isNaN(timeDelta)) {
|
||||||
|
text = formatter(timeDelta);
|
||||||
|
sign = timeDelta < 0 ? "-" : timeDelta >= 1000 ? "+" : "";
|
||||||
|
} else {
|
||||||
|
text = "";
|
||||||
|
sign = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFormat(key) {
|
||||||
|
formatter = FORMATTER[key] || FORMATTER.long;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimestamp(timestamp) {
|
||||||
|
relativeTimestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateObject(domainObject) {
|
||||||
|
var model = domainObject.getModel(),
|
||||||
|
timestamp = model.timestamp,
|
||||||
|
formatKey = model.timerFormat,
|
||||||
|
actionCapability = domainObject.getCapability('action'),
|
||||||
|
actionKey = (timestamp === undefined) ?
|
||||||
|
'warp.timer.start' : 'warp.timer.restart';
|
||||||
|
|
||||||
|
updateFormat(formatKey);
|
||||||
|
updateTimestamp(timestamp);
|
||||||
|
|
||||||
|
relevantAction = actionCapability &&
|
||||||
|
actionCapability.getActions(actionKey)[0];
|
||||||
|
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleObjectChange(domainObject) {
|
||||||
|
if (domainObject) {
|
||||||
|
updateObject(domainObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModification() {
|
||||||
|
handleObjectChange($scope.domainObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
var lastSign = sign, lastText = text;
|
||||||
|
lastTimestamp = now();
|
||||||
|
update();
|
||||||
|
// We're running in an animation frame, not in a digest cycle.
|
||||||
|
// We need to trigger a digest cycle if our displayable data
|
||||||
|
// changes.
|
||||||
|
if (lastSign !== sign || lastText !== text) {
|
||||||
|
$scope.$apply();
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
$window.requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$window.requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
// Pull in the timer format from the domain object model
|
||||||
|
$scope.$watch('domainObject', handleObjectChange);
|
||||||
|
$scope.$watch('model.modified', handleModification);
|
||||||
|
|
||||||
|
// When the scope is destroyed, stop requesting anim. frames
|
||||||
|
$scope.$on('$destroy', function () {
|
||||||
|
active = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the glyph to display for the start/restart button.
|
||||||
|
* @returns {string} glyph to display
|
||||||
|
*/
|
||||||
|
buttonGlyph: function () {
|
||||||
|
return relevantAction ?
|
||||||
|
relevantAction.getMetadata().glyph : "";
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the text to show for the start/restart button
|
||||||
|
* (e.g. in a tooltip)
|
||||||
|
* @returns {string} name of the action
|
||||||
|
*/
|
||||||
|
buttonText: function () {
|
||||||
|
return relevantAction ?
|
||||||
|
relevantAction.getMetadata().name : "";
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Perform the action associated with the start/restart button.
|
||||||
|
*/
|
||||||
|
clickButton: function () {
|
||||||
|
if (relevantAction) {
|
||||||
|
relevantAction.perform();
|
||||||
|
updateObject($scope.domainObject);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the sign (+ or -) of the current timer value, as
|
||||||
|
* displayable text.
|
||||||
|
* @returns {string} sign of the current timer value
|
||||||
|
*/
|
||||||
|
sign: function () {
|
||||||
|
return sign;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the text to display for the current timer value.
|
||||||
|
* @returns {string} current timer value
|
||||||
|
*/
|
||||||
|
text: function () {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimerController;
|
||||||
|
}
|
||||||
|
);
|
60
platform/features/clock/src/controllers/TimerFormatter.js
Normal file
60
platform/features/clock/src/controllers/TimerFormatter.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['moment', 'moment-duration-format'],
|
||||||
|
function (moment) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var SHORT_FORMAT = "HH:mm:ss",
|
||||||
|
LONG_FORMAT = "d[D] HH:mm:ss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides formatting functions for Timers.
|
||||||
|
*
|
||||||
|
* Display formats for timers are a little different from what
|
||||||
|
* moment.js provides, so we have custom logic here. This specifically
|
||||||
|
* supports `TimerController`.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimerFormatter() {
|
||||||
|
|
||||||
|
// Round this timestamp down to the second boundary
|
||||||
|
// (e.g. 1124ms goes down to 1000ms, -2400ms goes down to -3000ms)
|
||||||
|
function toWholeSeconds(duration) {
|
||||||
|
return Math.abs(Math.floor(duration / 1000) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short-form format, e.g. 02:22:11
|
||||||
|
function short(duration) {
|
||||||
|
return moment.duration(toWholeSeconds(duration), 'ms')
|
||||||
|
.format(SHORT_FORMAT, { trim: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long-form format, e.g. 3d 02:22:11
|
||||||
|
function long(duration) {
|
||||||
|
return moment.duration(toWholeSeconds(duration), 'ms')
|
||||||
|
.format(LONG_FORMAT, { trim: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Format a duration for display, using the short form.
|
||||||
|
* (e.g. 03:33:11)
|
||||||
|
* @param {number} duration the duration, in milliseconds
|
||||||
|
* @param {boolean} sign true if positive
|
||||||
|
*/
|
||||||
|
short: short,
|
||||||
|
/**
|
||||||
|
* Format a duration for display, using the long form.
|
||||||
|
* (e.g. 0d 03:33:11)
|
||||||
|
* @param {number} duration the duration, in milliseconds
|
||||||
|
* @param {boolean} sign true if positive
|
||||||
|
*/
|
||||||
|
long: long
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimerFormatter;
|
||||||
|
}
|
||||||
|
);
|
38
platform/features/clock/src/indicators/ClockIndicator.js
Normal file
38
platform/features/clock/src/indicators/ClockIndicator.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['moment'],
|
||||||
|
function (moment) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicator that displays the current UTC time in the status area.
|
||||||
|
* @implements Indicator
|
||||||
|
*/
|
||||||
|
function ClockIndicator(tickerService, CLOCK_INDICATOR_FORMAT) {
|
||||||
|
var text = "";
|
||||||
|
|
||||||
|
tickerService.listen(function (timestamp) {
|
||||||
|
text = moment.utc(timestamp).format(CLOCK_INDICATOR_FORMAT) + " UTC";
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getGlyph: function () {
|
||||||
|
return "C";
|
||||||
|
},
|
||||||
|
getGlyphClass: function () {
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
getText: function () {
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
getDescription: function () {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClockIndicator;
|
||||||
|
}
|
||||||
|
);
|
68
platform/features/clock/src/services/TickerService.js
Normal file
68
platform/features/clock/src/services/TickerService.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['moment'],
|
||||||
|
function (moment) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls functions every second, as close to the actual second
|
||||||
|
* tick as is feasible.
|
||||||
|
* @constructor
|
||||||
|
* @param $timeout Angular's $timeout
|
||||||
|
* @param {Function} now function to provide the current time in ms
|
||||||
|
*/
|
||||||
|
function TickerService($timeout, now) {
|
||||||
|
var callbacks = [],
|
||||||
|
last = now() - 1000;
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
var timestamp = now(),
|
||||||
|
millis = timestamp % 1000;
|
||||||
|
|
||||||
|
// Only update callbacks if a second has actually passed.
|
||||||
|
if (timestamp >= last + 1000) {
|
||||||
|
callbacks.forEach(function (callback) {
|
||||||
|
callback(timestamp);
|
||||||
|
});
|
||||||
|
last = timestamp - millis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to update at exactly the next second
|
||||||
|
$timeout(tick, 1000 - millis, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Listen for clock ticks. The provided callback will
|
||||||
|
* be invoked with the current timestamp (in milliseconds
|
||||||
|
* since Jan 1 1970) at regular intervals, as near to the
|
||||||
|
* second boundary as possible.
|
||||||
|
*
|
||||||
|
* @method listen
|
||||||
|
* @name TickerService#listen
|
||||||
|
* @param {Function} callback callback to invoke
|
||||||
|
* @returns {Function} a function to unregister this listener
|
||||||
|
*/
|
||||||
|
listen: function (callback) {
|
||||||
|
callbacks.push(callback);
|
||||||
|
|
||||||
|
// Provide immediate feedback
|
||||||
|
callback(last);
|
||||||
|
|
||||||
|
// Provide a deregistration function
|
||||||
|
return function () {
|
||||||
|
callbacks = callbacks.filter(function (cb) {
|
||||||
|
return cb !== callback;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return TickerService;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,66 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/actions/AbstractStartTimerAction"],
|
||||||
|
function (AbstractStartTimerAction) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
describe("A timer's start/restart action", function () {
|
||||||
|
var mockNow,
|
||||||
|
mockDomainObject,
|
||||||
|
mockPersistence,
|
||||||
|
testModel,
|
||||||
|
action;
|
||||||
|
|
||||||
|
function asPromise(value) {
|
||||||
|
return (value || {}).then ? value : {
|
||||||
|
then: function (callback) {
|
||||||
|
return asPromise(callback(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockNow = jasmine.createSpy('now');
|
||||||
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getCapability', 'useCapability' ]
|
||||||
|
);
|
||||||
|
mockPersistence = jasmine.createSpyObj(
|
||||||
|
'persistence',
|
||||||
|
['persist']
|
||||||
|
);
|
||||||
|
|
||||||
|
mockDomainObject.getCapability.andCallFake(function (c) {
|
||||||
|
return (c === 'persistence') && mockPersistence;
|
||||||
|
});
|
||||||
|
mockDomainObject.useCapability.andCallFake(function (c, v) {
|
||||||
|
if (c === 'mutation') {
|
||||||
|
testModel = v(testModel) || testModel;
|
||||||
|
return asPromise(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testModel = {};
|
||||||
|
|
||||||
|
action = new AbstractStartTimerAction(mockNow, {
|
||||||
|
domainObject: mockDomainObject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the model with a timestamp and persists", function () {
|
||||||
|
mockNow.andReturn(12000);
|
||||||
|
action.perform();
|
||||||
|
expect(testModel.timestamp).toEqual(12000);
|
||||||
|
expect(mockPersistence.persist).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not truncate milliseconds", function () {
|
||||||
|
mockNow.andReturn(42321);
|
||||||
|
action.perform();
|
||||||
|
expect(testModel.timestamp).toEqual(42321);
|
||||||
|
expect(mockPersistence.persist).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,76 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/actions/RestartTimerAction"],
|
||||||
|
function (RestartTimerAction) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
describe("A timer's restart action", function () {
|
||||||
|
var mockNow,
|
||||||
|
mockDomainObject,
|
||||||
|
mockPersistence,
|
||||||
|
testModel,
|
||||||
|
testContext,
|
||||||
|
action;
|
||||||
|
|
||||||
|
function asPromise(value) {
|
||||||
|
return (value || {}).then ? value : {
|
||||||
|
then: function (callback) {
|
||||||
|
return asPromise(callback(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockNow = jasmine.createSpy('now');
|
||||||
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getCapability', 'useCapability', 'getModel' ]
|
||||||
|
);
|
||||||
|
mockPersistence = jasmine.createSpyObj(
|
||||||
|
'persistence',
|
||||||
|
['persist']
|
||||||
|
);
|
||||||
|
|
||||||
|
mockDomainObject.getCapability.andCallFake(function (c) {
|
||||||
|
return (c === 'persistence') && mockPersistence;
|
||||||
|
});
|
||||||
|
mockDomainObject.useCapability.andCallFake(function (c, v) {
|
||||||
|
if (c === 'mutation') {
|
||||||
|
testModel = v(testModel) || testModel;
|
||||||
|
return asPromise(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mockDomainObject.getModel.andCallFake(function () {
|
||||||
|
return testModel;
|
||||||
|
});
|
||||||
|
|
||||||
|
testModel = {};
|
||||||
|
testContext = { domainObject: mockDomainObject };
|
||||||
|
|
||||||
|
action = new RestartTimerAction(mockNow, testContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the model with a timestamp and persists", function () {
|
||||||
|
mockNow.andReturn(12000);
|
||||||
|
action.perform();
|
||||||
|
expect(testModel.timestamp).toEqual(12000);
|
||||||
|
expect(mockPersistence.persist).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies only to timers with a target time", function () {
|
||||||
|
testModel.type = 'warp.timer';
|
||||||
|
testModel.timestamp = 12000;
|
||||||
|
expect(RestartTimerAction.appliesTo(testContext)).toBeTruthy();
|
||||||
|
|
||||||
|
testModel.type = 'warp.timer';
|
||||||
|
testModel.timestamp = undefined;
|
||||||
|
expect(RestartTimerAction.appliesTo(testContext)).toBeFalsy();
|
||||||
|
|
||||||
|
testModel.type = 'warp.clock';
|
||||||
|
testModel.timestamp = 12000;
|
||||||
|
expect(RestartTimerAction.appliesTo(testContext)).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
76
platform/features/clock/test/actions/StartTimerActionSpec.js
Normal file
76
platform/features/clock/test/actions/StartTimerActionSpec.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/actions/StartTimerAction"],
|
||||||
|
function (StartTimerAction) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
describe("A timer's start action", function () {
|
||||||
|
var mockNow,
|
||||||
|
mockDomainObject,
|
||||||
|
mockPersistence,
|
||||||
|
testModel,
|
||||||
|
testContext,
|
||||||
|
action;
|
||||||
|
|
||||||
|
function asPromise(value) {
|
||||||
|
return (value || {}).then ? value : {
|
||||||
|
then: function (callback) {
|
||||||
|
return asPromise(callback(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockNow = jasmine.createSpy('now');
|
||||||
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getCapability', 'useCapability', 'getModel' ]
|
||||||
|
);
|
||||||
|
mockPersistence = jasmine.createSpyObj(
|
||||||
|
'persistence',
|
||||||
|
['persist']
|
||||||
|
);
|
||||||
|
|
||||||
|
mockDomainObject.getCapability.andCallFake(function (c) {
|
||||||
|
return (c === 'persistence') && mockPersistence;
|
||||||
|
});
|
||||||
|
mockDomainObject.useCapability.andCallFake(function (c, v) {
|
||||||
|
if (c === 'mutation') {
|
||||||
|
testModel = v(testModel) || testModel;
|
||||||
|
return asPromise(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mockDomainObject.getModel.andCallFake(function () {
|
||||||
|
return testModel;
|
||||||
|
});
|
||||||
|
|
||||||
|
testModel = {};
|
||||||
|
testContext = { domainObject: mockDomainObject };
|
||||||
|
|
||||||
|
action = new StartTimerAction(mockNow, testContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the model with a timestamp and persists", function () {
|
||||||
|
mockNow.andReturn(12000);
|
||||||
|
action.perform();
|
||||||
|
expect(testModel.timestamp).toEqual(12000);
|
||||||
|
expect(mockPersistence.persist).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies only to timers without a target time", function () {
|
||||||
|
testModel.type = 'warp.timer';
|
||||||
|
testModel.timestamp = 12000;
|
||||||
|
expect(StartTimerAction.appliesTo(testContext)).toBeFalsy();
|
||||||
|
|
||||||
|
testModel.type = 'warp.timer';
|
||||||
|
testModel.timestamp = undefined;
|
||||||
|
expect(StartTimerAction.appliesTo(testContext)).toBeTruthy();
|
||||||
|
|
||||||
|
testModel.type = 'warp.clock';
|
||||||
|
testModel.timestamp = 12000;
|
||||||
|
expect(StartTimerAction.appliesTo(testContext)).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,83 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/controllers/ClockController"],
|
||||||
|
function (ClockController) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Wed, 03 Jun 2015 17:56:14 GMT
|
||||||
|
var TEST_TIMESTAMP = 1433354174000;
|
||||||
|
|
||||||
|
describe("A clock view's controller", function () {
|
||||||
|
var mockScope,
|
||||||
|
mockTicker,
|
||||||
|
mockUnticker,
|
||||||
|
mockDomainObject,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockScope = jasmine.createSpyObj('$scope', ['$watch', '$on']);
|
||||||
|
mockTicker = jasmine.createSpyObj('ticker', ['listen']);
|
||||||
|
mockUnticker = jasmine.createSpy('unticker');
|
||||||
|
|
||||||
|
mockTicker.listen.andReturn(mockUnticker);
|
||||||
|
|
||||||
|
controller = new ClockController(mockScope, mockTicker);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("watches for clock format from the domain object model", function () {
|
||||||
|
expect(mockScope.$watch).toHaveBeenCalledWith(
|
||||||
|
"model.clockFormat",
|
||||||
|
jasmine.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribes to clock ticks", function () {
|
||||||
|
expect(mockTicker.listen)
|
||||||
|
.toHaveBeenCalledWith(jasmine.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribes to ticks when destroyed", function () {
|
||||||
|
// Make sure $destroy is being listened for...
|
||||||
|
expect(mockScope.$on.mostRecentCall.args[0]).toEqual('$destroy');
|
||||||
|
expect(mockUnticker).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// ...and makes sure that its listener unsubscribes from ticker
|
||||||
|
mockScope.$on.mostRecentCall.args[1]();
|
||||||
|
expect(mockUnticker).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats using the format string from the model", function () {
|
||||||
|
mockTicker.listen.mostRecentCall.args[0](TEST_TIMESTAMP);
|
||||||
|
mockScope.$watch.mostRecentCall.args[1]([
|
||||||
|
"YYYY-DDD hh:mm:ss",
|
||||||
|
"clock24"
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(controller.zone()).toEqual("UTC");
|
||||||
|
expect(controller.text()).toEqual("2015-154 17:56:14");
|
||||||
|
expect(controller.ampm()).toEqual("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats 12-hour time", function () {
|
||||||
|
mockTicker.listen.mostRecentCall.args[0](TEST_TIMESTAMP);
|
||||||
|
mockScope.$watch.mostRecentCall.args[1]([
|
||||||
|
"YYYY-DDD hh:mm:ss",
|
||||||
|
"clock12"
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(controller.zone()).toEqual("UTC");
|
||||||
|
expect(controller.text()).toEqual("2015-154 05:56:14");
|
||||||
|
expect(controller.ampm()).toEqual("PM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw exceptions when clockFormat is undefined", function () {
|
||||||
|
mockTicker.listen.mostRecentCall.args[0](TEST_TIMESTAMP);
|
||||||
|
expect(function () {
|
||||||
|
mockScope.$watch.mostRecentCall.args[1](undefined);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,63 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/controllers/RefreshingController"],
|
||||||
|
function (RefreshingController) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
describe("The refreshing controller", function () {
|
||||||
|
var mockScope,
|
||||||
|
mockTicker,
|
||||||
|
mockUnticker,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockScope = jasmine.createSpyObj('$scope', ['$on']);
|
||||||
|
mockTicker = jasmine.createSpyObj('ticker', ['listen']);
|
||||||
|
mockUnticker = jasmine.createSpy('unticker');
|
||||||
|
|
||||||
|
mockTicker.listen.andReturn(mockUnticker);
|
||||||
|
|
||||||
|
controller = new RefreshingController(mockScope, mockTicker);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes the represented object on every tick", function () {
|
||||||
|
var mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getCapability' ]
|
||||||
|
),
|
||||||
|
mockPersistence = jasmine.createSpyObj(
|
||||||
|
'persistence',
|
||||||
|
[ 'persist', 'refresh' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
mockDomainObject.getCapability.andCallFake(function (c) {
|
||||||
|
return (c === 'persistence') && mockPersistence;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockScope.domainObject = mockDomainObject;
|
||||||
|
|
||||||
|
mockTicker.listen.mostRecentCall.args[0](12321);
|
||||||
|
expect(mockPersistence.refresh).toHaveBeenCalled();
|
||||||
|
expect(mockPersistence.persist).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribes to clock ticks", function () {
|
||||||
|
expect(mockTicker.listen)
|
||||||
|
.toHaveBeenCalledWith(jasmine.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribes to ticks when destroyed", function () {
|
||||||
|
// Make sure $destroy is being listened for...
|
||||||
|
expect(mockScope.$on.mostRecentCall.args[0]).toEqual('$destroy');
|
||||||
|
expect(mockUnticker).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// ...and makes sure that its listener unsubscribes from ticker
|
||||||
|
mockScope.$on.mostRecentCall.args[1]();
|
||||||
|
expect(mockUnticker).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
178
platform/features/clock/test/controllers/TimerControllerSpec.js
Normal file
178
platform/features/clock/test/controllers/TimerControllerSpec.js
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/controllers/TimerController"],
|
||||||
|
function (TimerController) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Wed, 03 Jun 2015 17:56:14 GMT
|
||||||
|
var TEST_TIMESTAMP = 1433354174000;
|
||||||
|
|
||||||
|
describe("A timer view's controller", function () {
|
||||||
|
var mockScope,
|
||||||
|
mockWindow,
|
||||||
|
mockNow,
|
||||||
|
mockDomainObject,
|
||||||
|
mockActionCapability,
|
||||||
|
mockStart,
|
||||||
|
mockRestart,
|
||||||
|
testModel,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
function invokeWatch(expr, value) {
|
||||||
|
mockScope.$watch.calls.forEach(function (call) {
|
||||||
|
if (call.args[0] === expr) {
|
||||||
|
call.args[1](value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockScope = jasmine.createSpyObj(
|
||||||
|
'$scope',
|
||||||
|
['$watch', '$on', '$apply']
|
||||||
|
);
|
||||||
|
mockWindow = jasmine.createSpyObj(
|
||||||
|
'$window',
|
||||||
|
['requestAnimationFrame']
|
||||||
|
);
|
||||||
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getCapability', 'useCapability', 'getModel' ]
|
||||||
|
);
|
||||||
|
mockActionCapability = jasmine.createSpyObj(
|
||||||
|
'action',
|
||||||
|
['getActions']
|
||||||
|
);
|
||||||
|
mockStart = jasmine.createSpyObj(
|
||||||
|
'start',
|
||||||
|
['getMetadata', 'perform']
|
||||||
|
);
|
||||||
|
mockRestart = jasmine.createSpyObj(
|
||||||
|
'restart',
|
||||||
|
['getMetadata', 'perform']
|
||||||
|
);
|
||||||
|
mockNow = jasmine.createSpy('now');
|
||||||
|
|
||||||
|
mockDomainObject.getCapability.andCallFake(function (c) {
|
||||||
|
return (c === 'action') && mockActionCapability;
|
||||||
|
});
|
||||||
|
mockDomainObject.getModel.andCallFake(function () {
|
||||||
|
return testModel;
|
||||||
|
});
|
||||||
|
mockActionCapability.getActions.andCallFake(function (k) {
|
||||||
|
return [{
|
||||||
|
'warp.timer.start': mockStart,
|
||||||
|
'warp.timer.restart': mockRestart
|
||||||
|
}[k]];
|
||||||
|
});
|
||||||
|
mockStart.getMetadata.andReturn({ glyph: "S", name: "Start" });
|
||||||
|
mockRestart.getMetadata.andReturn({ glyph: "R", name: "Restart" });
|
||||||
|
mockScope.domainObject = mockDomainObject;
|
||||||
|
|
||||||
|
testModel = {};
|
||||||
|
|
||||||
|
controller = new TimerController(mockScope, mockWindow, mockNow);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("watches for the domain object in view", function () {
|
||||||
|
expect(mockScope.$watch).toHaveBeenCalledWith(
|
||||||
|
"domainObject",
|
||||||
|
jasmine.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("watches for domain object modifications", function () {
|
||||||
|
expect(mockScope.$watch).toHaveBeenCalledWith(
|
||||||
|
"model.modified",
|
||||||
|
jasmine.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates on a timer", function () {
|
||||||
|
expect(mockWindow.requestAnimationFrame)
|
||||||
|
.toHaveBeenCalledWith(jasmine.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays nothing when there is no target", function () {
|
||||||
|
// Notify that domain object is available via scope
|
||||||
|
invokeWatch('domainObject', mockDomainObject);
|
||||||
|
mockNow.andReturn(TEST_TIMESTAMP);
|
||||||
|
mockWindow.requestAnimationFrame.mostRecentCall.args[0]();
|
||||||
|
expect(controller.sign()).toEqual("");
|
||||||
|
expect(controller.text()).toEqual("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats time to display relative to target", function () {
|
||||||
|
testModel.timestamp = TEST_TIMESTAMP;
|
||||||
|
testModel.timerFormat = 'long';
|
||||||
|
// Notify that domain object is available via scope
|
||||||
|
invokeWatch('domainObject', mockDomainObject);
|
||||||
|
|
||||||
|
mockNow.andReturn(TEST_TIMESTAMP + 121000);
|
||||||
|
mockWindow.requestAnimationFrame.mostRecentCall.args[0]();
|
||||||
|
expect(controller.sign()).toEqual("+");
|
||||||
|
expect(controller.text()).toEqual("0D 00:02:01");
|
||||||
|
|
||||||
|
mockNow.andReturn(TEST_TIMESTAMP - 121000);
|
||||||
|
mockWindow.requestAnimationFrame.mostRecentCall.args[0]();
|
||||||
|
expect(controller.sign()).toEqual("-");
|
||||||
|
expect(controller.text()).toEqual("0D 00:02:01");
|
||||||
|
|
||||||
|
mockNow.andReturn(TEST_TIMESTAMP);
|
||||||
|
mockWindow.requestAnimationFrame.mostRecentCall.args[0]();
|
||||||
|
expect(controller.sign()).toEqual("");
|
||||||
|
expect(controller.text()).toEqual("0D 00:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows glyph & name for the applicable start/restart action", function () {
|
||||||
|
invokeWatch('domainObject', mockDomainObject);
|
||||||
|
expect(controller.buttonGlyph()).toEqual("S");
|
||||||
|
expect(controller.buttonText()).toEqual("Start");
|
||||||
|
|
||||||
|
testModel.timestamp = 12321;
|
||||||
|
invokeWatch('model.modified', 1);
|
||||||
|
expect(controller.buttonGlyph()).toEqual("R");
|
||||||
|
expect(controller.buttonText()).toEqual("Restart");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs correct start/restart action on click", function () {
|
||||||
|
invokeWatch('domainObject', mockDomainObject);
|
||||||
|
expect(mockStart.perform).not.toHaveBeenCalled();
|
||||||
|
controller.clickButton();
|
||||||
|
expect(mockStart.perform).toHaveBeenCalled();
|
||||||
|
|
||||||
|
testModel.timestamp = 12321;
|
||||||
|
invokeWatch('model.modified', 1);
|
||||||
|
expect(mockRestart.perform).not.toHaveBeenCalled();
|
||||||
|
controller.clickButton();
|
||||||
|
expect(mockRestart.perform).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops requesting animation frames when destroyed", function () {
|
||||||
|
var initialCount = mockWindow.requestAnimationFrame.calls.length;
|
||||||
|
|
||||||
|
// First, check that normally new frames keep getting requested
|
||||||
|
mockWindow.requestAnimationFrame.mostRecentCall.args[0]();
|
||||||
|
expect(mockWindow.requestAnimationFrame.calls.length)
|
||||||
|
.toEqual(initialCount + 1);
|
||||||
|
mockWindow.requestAnimationFrame.mostRecentCall.args[0]();
|
||||||
|
expect(mockWindow.requestAnimationFrame.calls.length)
|
||||||
|
.toEqual(initialCount + 2);
|
||||||
|
|
||||||
|
// Now, verify that it stops after $destroy
|
||||||
|
expect(mockScope.$on.mostRecentCall.args[0])
|
||||||
|
.toEqual('$destroy');
|
||||||
|
mockScope.$on.mostRecentCall.args[1]();
|
||||||
|
|
||||||
|
// Frames should no longer get requested
|
||||||
|
mockWindow.requestAnimationFrame.mostRecentCall.args[0]();
|
||||||
|
expect(mockWindow.requestAnimationFrame.calls.length)
|
||||||
|
.toEqual(initialCount + 2);
|
||||||
|
mockWindow.requestAnimationFrame.mostRecentCall.args[0]();
|
||||||
|
expect(mockWindow.requestAnimationFrame.calls.length)
|
||||||
|
.toEqual(initialCount + 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,96 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/controllers/TimerFormatter"],
|
||||||
|
function (TimerFormatter) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var MS_IN_SEC = 1000,
|
||||||
|
MS_IN_MIN = MS_IN_SEC * 60,
|
||||||
|
MS_IN_HR = MS_IN_MIN * 60,
|
||||||
|
MS_IN_DAY = MS_IN_HR * 24;
|
||||||
|
|
||||||
|
describe("The timer value formatter", function () {
|
||||||
|
var formatter = new TimerFormatter();
|
||||||
|
|
||||||
|
function sum(a, b) {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDuration(days, hours, mins, secs) {
|
||||||
|
return [
|
||||||
|
days * MS_IN_DAY,
|
||||||
|
hours * MS_IN_HR,
|
||||||
|
mins * MS_IN_MIN,
|
||||||
|
secs * MS_IN_SEC
|
||||||
|
].reduce(sum, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function twoDigits(n) {
|
||||||
|
return n < 10 ? ('0' + n) : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("formats short-form values (no days)", function () {
|
||||||
|
expect(formatter.short(toDuration(0, 123, 2, 3) + 123))
|
||||||
|
.toEqual("123:02:03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats negative short-form values (no days)", function () {
|
||||||
|
expect(formatter.short(-toDuration(0, 123, 2, 3) + 123))
|
||||||
|
.toEqual("123:02:03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats long-form values (with days)", function () {
|
||||||
|
expect(formatter.long(toDuration(0, 123, 2, 3) + 123))
|
||||||
|
.toEqual("5D 03:02:03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats negative long-form values (no days)", function () {
|
||||||
|
expect(formatter.long(-toDuration(0, 123, 2, 3) + 123))
|
||||||
|
.toEqual("5D 03:02:03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds seconds down for positive durations", function () {
|
||||||
|
expect(formatter.short(MS_IN_SEC + 600))
|
||||||
|
.toEqual("00:00:01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds seconds up for negative durations", function () {
|
||||||
|
expect(formatter.short(-MS_IN_SEC - 600))
|
||||||
|
.toEqual("00:00:02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-formats correctly around negative time borders", function () {
|
||||||
|
expect(formatter.short(-1)).toEqual("00:00:01");
|
||||||
|
expect(formatter.short(-1000)).toEqual("00:00:01");
|
||||||
|
expect(formatter.short(-1001)).toEqual("00:00:02");
|
||||||
|
expect(formatter.short(-2000)).toEqual("00:00:02");
|
||||||
|
expect(formatter.short(-59001)).toEqual("00:01:00");
|
||||||
|
expect(formatter.short(-60000)).toEqual("00:01:00");
|
||||||
|
|
||||||
|
expect(formatter.short(-MS_IN_HR + 999)).toEqual("01:00:00");
|
||||||
|
expect(formatter.short(-MS_IN_HR)).toEqual("01:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("differentiates between values around zero", function () {
|
||||||
|
// These are more than 1000 ms apart so should not appear
|
||||||
|
// as the same second
|
||||||
|
expect(formatter.short(-999))
|
||||||
|
.not.toEqual(formatter.short(999));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles negative days", function () {
|
||||||
|
expect(formatter.long(-10 * MS_IN_DAY))
|
||||||
|
.toEqual("10D 00:00:00");
|
||||||
|
expect(formatter.long(-10 * MS_IN_DAY + 100))
|
||||||
|
.toEqual("10D 00:00:00");
|
||||||
|
expect(formatter.long(-10 * MS_IN_DAY + 999))
|
||||||
|
.toEqual("10D 00:00:00");
|
||||||
|
|
||||||
|
expect(formatter.short(-10 * MS_IN_DAY + 100))
|
||||||
|
.toEqual("240:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,40 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/indicators/ClockIndicator"],
|
||||||
|
function (ClockIndicator) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Wed, 03 Jun 2015 17:56:14 GMT
|
||||||
|
var TEST_TIMESTAMP = 1433354174000,
|
||||||
|
TEST_FORMAT = "YYYY-DDD HH:mm:ss";
|
||||||
|
|
||||||
|
describe("The clock indicator", function () {
|
||||||
|
var mockTicker,
|
||||||
|
mockUnticker,
|
||||||
|
indicator;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockTicker = jasmine.createSpyObj('ticker', ['listen']);
|
||||||
|
mockUnticker = jasmine.createSpy('unticker');
|
||||||
|
|
||||||
|
mockTicker.listen.andReturn(mockUnticker);
|
||||||
|
|
||||||
|
indicator = new ClockIndicator(mockTicker, TEST_FORMAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the current time", function () {
|
||||||
|
mockTicker.listen.mostRecentCall.args[0](TEST_TIMESTAMP);
|
||||||
|
expect(indicator.getText()).toEqual("2015-154 17:56:14 UTC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("implements the Indicator interface", function () {
|
||||||
|
expect(indicator.getGlyph()).toEqual(jasmine.any(String));
|
||||||
|
expect(indicator.getGlyphClass()).toEqual(jasmine.any(String));
|
||||||
|
expect(indicator.getText()).toEqual(jasmine.any(String));
|
||||||
|
expect(indicator.getDescription()).toEqual(jasmine.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
43
platform/features/clock/test/services/TickerServiceSpec.js
Normal file
43
platform/features/clock/test/services/TickerServiceSpec.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/services/TickerService"],
|
||||||
|
function (TickerService) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var TEST_TIMESTAMP = 1433354174000;
|
||||||
|
|
||||||
|
describe("The ticker service", function () {
|
||||||
|
var mockTimeout,
|
||||||
|
mockNow,
|
||||||
|
mockCallback,
|
||||||
|
tickerService;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockTimeout = jasmine.createSpy('$timeout');
|
||||||
|
mockNow = jasmine.createSpy('now');
|
||||||
|
mockCallback = jasmine.createSpy('callback');
|
||||||
|
|
||||||
|
mockNow.andReturn(TEST_TIMESTAMP);
|
||||||
|
|
||||||
|
tickerService = new TickerService(mockTimeout, mockNow);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies listeners of clock ticks", function () {
|
||||||
|
tickerService.listen(mockCallback);
|
||||||
|
mockNow.andReturn(TEST_TIMESTAMP + 12321);
|
||||||
|
mockTimeout.mostRecentCall.args[0]();
|
||||||
|
expect(mockCallback)
|
||||||
|
.toHaveBeenCalledWith(TEST_TIMESTAMP + 12321);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows listeners to unregister", function () {
|
||||||
|
tickerService.listen(mockCallback)(); // Unregister immediately
|
||||||
|
mockNow.andReturn(TEST_TIMESTAMP + 12321);
|
||||||
|
mockTimeout.mostRecentCall.args[0]();
|
||||||
|
expect(mockCallback).not
|
||||||
|
.toHaveBeenCalledWith(TEST_TIMESTAMP + 12321);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
11
platform/features/clock/test/suite.json
Normal file
11
platform/features/clock/test/suite.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
"actions/AbstractStartTimerAction",
|
||||||
|
"actions/RestartTimerAction",
|
||||||
|
"actions/StartTimerAction",
|
||||||
|
"controllers/ClockController",
|
||||||
|
"controllers/RefreshingController",
|
||||||
|
"controllers/TimerController",
|
||||||
|
"controllers/TimerFormatter",
|
||||||
|
"indicators/ClockIndicator",
|
||||||
|
"services/TickerService"
|
||||||
|
]
|
70
platform/features/timeline/README.md
Normal file
70
platform/features/timeline/README.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
This bundle provides the Timeline domain object type, as well
|
||||||
|
as other associated domain object types and relevant views.
|
||||||
|
|
||||||
|
# Implementation notes
|
||||||
|
|
||||||
|
## Model Properties
|
||||||
|
|
||||||
|
The properties below record properties relevant to using and
|
||||||
|
understanding timelines based on their JSON representation.
|
||||||
|
Additional common properties, such as `modified`
|
||||||
|
or `persisted` timestamps, may also be present.
|
||||||
|
|
||||||
|
### Timeline Model
|
||||||
|
|
||||||
|
A timeline's model looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"type": "warp.timeline",
|
||||||
|
"start": {
|
||||||
|
"timestamp": <number> (milliseconds since epoch),
|
||||||
|
"epoch": <string> (currently, always "SET")
|
||||||
|
},
|
||||||
|
"capacity": <number> (optional; battery capacity in watt-hours)
|
||||||
|
"composition": <string[]> (array of identifiers for contained objects)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The identifiers in a timeline's `composition` field should refer to
|
||||||
|
other Timeline objects, or to Activity objects.
|
||||||
|
|
||||||
|
### Activity Model
|
||||||
|
|
||||||
|
An activity's model looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"type": "warp.activity",
|
||||||
|
"start": {
|
||||||
|
"timestamp": <number> (milliseconds since epoch),
|
||||||
|
"epoch": <string> (currently, always "SET")
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"timestamp": <number> (duration of this activity, in milliseconds)
|
||||||
|
"epoch": "SET" (this is ignored)
|
||||||
|
},
|
||||||
|
"relationships": {
|
||||||
|
"modes": <string[]> (array of applicable Activity Mode ids)
|
||||||
|
},
|
||||||
|
"link": <string> (optional; URL linking to associated external resource)
|
||||||
|
"composition": <string[]> (array of identifiers for contained objects)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The identifiers in a timeline's `composition` field should only refer to
|
||||||
|
other Activity objects.
|
||||||
|
|
||||||
|
### Activity Mode Model
|
||||||
|
|
||||||
|
An activity mode's model looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"type": "warp.mode",
|
||||||
|
"resources": {
|
||||||
|
"comms": <number> (communications utilization, in Kbps)
|
||||||
|
"power": <number> (power utilization, in watts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
372
platform/features/timeline/bundle.json
Normal file
372
platform/features/timeline/bundle.json
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
{
|
||||||
|
"name": "WARP Timeline",
|
||||||
|
"description": "Resources, templates, CSS, and code for Timelines.",
|
||||||
|
"resources": "res",
|
||||||
|
"extensions": {
|
||||||
|
"constants": [
|
||||||
|
{
|
||||||
|
"key": "TIMELINE_MINIMUM_DURATION",
|
||||||
|
"description": "The minimum duration to display in a timeline view (one hour.)",
|
||||||
|
"value": 3600000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "TIMELINE_MAXIMUM_OFFSCREEN",
|
||||||
|
"description": "Maximum amount, in pixels, of a Gantt bar which may go off screen.",
|
||||||
|
"value": 1000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "TIMELINE_ZOOM_CONFIGURATION",
|
||||||
|
"description": "Describes major tick sizes in milliseconds, and width in pixels.",
|
||||||
|
"value": {
|
||||||
|
"levels": [
|
||||||
|
1000,
|
||||||
|
2000,
|
||||||
|
5000,
|
||||||
|
|
||||||
|
10000,
|
||||||
|
20000,
|
||||||
|
30000,
|
||||||
|
60000,
|
||||||
|
|
||||||
|
120000,
|
||||||
|
300000,
|
||||||
|
600000,
|
||||||
|
|
||||||
|
1200000,
|
||||||
|
1800000,
|
||||||
|
3600000,
|
||||||
|
7200000,
|
||||||
|
|
||||||
|
14400000,
|
||||||
|
28800000,
|
||||||
|
43200000,
|
||||||
|
86400000
|
||||||
|
],
|
||||||
|
"width": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"key": "warp.timeline",
|
||||||
|
"name": "Timeline",
|
||||||
|
"glyph": "S",
|
||||||
|
"description": "A container for arranging Timelines and Activities in time.",
|
||||||
|
"features": [ "creation" ],
|
||||||
|
"contains": [ "warp.timeline", "warp.activity" ],
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "Start date/time",
|
||||||
|
"control": "warp.datetime",
|
||||||
|
"required": true,
|
||||||
|
"property": [ "start" ],
|
||||||
|
"options": [ "SET" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Battery capacity (Watt-hours)",
|
||||||
|
"control": "textfield",
|
||||||
|
"required": false,
|
||||||
|
"conversion": "number",
|
||||||
|
"property": [ "capacity" ],
|
||||||
|
"pattern": "^-?\\d+(\\.\\d*)?$"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"model": { "composition": [] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warp.activity",
|
||||||
|
"name": "Activity",
|
||||||
|
"glyph": "a",
|
||||||
|
"features": [ "creation" ],
|
||||||
|
"contains": [ "warp.activity" ],
|
||||||
|
"description": "An action that takes place in time. You can define a start time and duration. Activities can be nested within other Activities, or within Timelines.",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "Start date/time",
|
||||||
|
"control": "warp.datetime",
|
||||||
|
"required": true,
|
||||||
|
"property": [ "start" ],
|
||||||
|
"options": [ "SET" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Duration",
|
||||||
|
"control": "warp.duration",
|
||||||
|
"required": true,
|
||||||
|
"property": [ "duration" ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"model": { "composition": [], "relationships": { "modes": [] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warp.mode",
|
||||||
|
"name": "Activity Mode",
|
||||||
|
"glyph": "A",
|
||||||
|
"features": [ "creation" ],
|
||||||
|
"description": "Define resource utilizations over time, then apply to an Activity.",
|
||||||
|
"model": { "resources": { "comms": 0, "power": 0 } },
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "Comms (Kbps)",
|
||||||
|
"control": "textfield",
|
||||||
|
"conversion": "number",
|
||||||
|
"pattern": "^-?\\d+(\\.\\d*)?$",
|
||||||
|
"property": [ "resources", "comms" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Power (watts)",
|
||||||
|
"control": "textfield",
|
||||||
|
"conversion": "number",
|
||||||
|
"pattern": "^-?\\d+(\\.\\d*)?$",
|
||||||
|
"property": [ "resources", "power" ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"key": "warp.values",
|
||||||
|
"name": "Values",
|
||||||
|
"glyph": "A",
|
||||||
|
"templateUrl": "templates/values.html",
|
||||||
|
"type": "warp.mode",
|
||||||
|
"uses": [ "cost" ],
|
||||||
|
"editable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warp.timeline",
|
||||||
|
"name": "Timeline",
|
||||||
|
"glyph": "S",
|
||||||
|
"type": "warp.timeline",
|
||||||
|
"description": "A timeline view of Timelines and Activities.",
|
||||||
|
"templateUrl": "templates/timeline.html",
|
||||||
|
"toolbar": {
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"method": "add",
|
||||||
|
"glyph": "+",
|
||||||
|
"control": "menu-button",
|
||||||
|
"text": "Add",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"name": "Timeline",
|
||||||
|
"glyph": "S",
|
||||||
|
"key": "warp.timeline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Activity",
|
||||||
|
"glyph": "a",
|
||||||
|
"key": "warp.activity"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"glyph": "\u00E9",
|
||||||
|
"description": "Graph resource utilization",
|
||||||
|
"control": "button",
|
||||||
|
"method": "toggleGraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glyph": "A",
|
||||||
|
"control": "dialog-button",
|
||||||
|
"description": "Apply Activity Modes...",
|
||||||
|
"title": "Apply Activity Modes",
|
||||||
|
"dialog": {
|
||||||
|
"control": "selector",
|
||||||
|
"name": "Modes",
|
||||||
|
"type": "warp.mode"
|
||||||
|
},
|
||||||
|
"property": "modes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glyph": "\u00E8",
|
||||||
|
"description": "Edit Activity Link",
|
||||||
|
"title": "Activity Link",
|
||||||
|
"control": "dialog-button",
|
||||||
|
"dialog": {
|
||||||
|
"control": "textfield",
|
||||||
|
"name": "Link",
|
||||||
|
"pattern": "^(ftp|https?)\\:\\/\\/\\w+(\\.\\w+)*(\\:\\d+)?(\\/\\S*)*$"
|
||||||
|
},
|
||||||
|
"property": "link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glyph": "\u0047",
|
||||||
|
"description": "Edit Properties...",
|
||||||
|
"control": "button",
|
||||||
|
"method": "properties"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"method": "remove",
|
||||||
|
"description": "Remove item",
|
||||||
|
"control": "button",
|
||||||
|
"glyph": "Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"representations": [
|
||||||
|
{
|
||||||
|
"key": "warp.gantt",
|
||||||
|
"templateUrl": "templates/activity-gantt.html",
|
||||||
|
"uses": [ "timespan", "type" ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"key": "timeline-tabular-swimlane-cols-tree",
|
||||||
|
"priority": "mandatory",
|
||||||
|
"templateUrl": "templates/tabular-swimlane-cols-tree.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "timeline-tabular-swimlane-cols-data",
|
||||||
|
"priority": "mandatory",
|
||||||
|
"templateUrl": "templates/tabular-swimlane-cols-data.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "timeline-resource-graphs",
|
||||||
|
"priority": "mandatory",
|
||||||
|
"templateUrl": "templates/resource-graphs.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "timeline-resource-graph-labels",
|
||||||
|
"priority": "mandatory",
|
||||||
|
"templateUrl": "templates/resource-graph-labels.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "timeline-legend-item",
|
||||||
|
"priority": "mandatory",
|
||||||
|
"templateUrl": "templates/legend-item.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "timeline-ticks",
|
||||||
|
"priority": "mandatory",
|
||||||
|
"templateUrl": "templates/ticks.html"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"key": "warp.datetime",
|
||||||
|
"templateUrl": "templates/controls/datetime.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warp.duration",
|
||||||
|
"templateUrl": "templates/controls/datetime.html"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"controllers": [
|
||||||
|
{
|
||||||
|
"key": "TimelineController",
|
||||||
|
"implementation": "controllers/TimelineController.js",
|
||||||
|
"depends": [ "$scope", "$q", "warp.objectLoader", "TIMELINE_MINIMUM_DURATION" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "TimelineGraphController",
|
||||||
|
"implementation": "controllers/TimelineGraphController.js",
|
||||||
|
"depends": [ "$scope", "warp.resources[]" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "WARPDateTimeController",
|
||||||
|
"implementation": "controllers/WARPDateTimeController.js",
|
||||||
|
"depends": [ "$scope" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "TimelineZoomController",
|
||||||
|
"implementation": "controllers/TimelineZoomController.js",
|
||||||
|
"depends": [ "$scope", "TIMELINE_ZOOM_CONFIGURATION" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "TimelineTickController",
|
||||||
|
"implementation": "controllers/TimelineTickController.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "TimelineTableController",
|
||||||
|
"implementation": "controllers/TimelineTableController.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "TimelineGanttController",
|
||||||
|
"implementation": "controllers/TimelineGanttController.js",
|
||||||
|
"depends": [ "TIMELINE_MAXIMUM_OFFSCREEN" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ActivityModeValuesController",
|
||||||
|
"implementation": "controllers/ActivityModeValuesController.js",
|
||||||
|
"depends": [ "warp.resources[]" ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"key": "timespan",
|
||||||
|
"implementation": "capabilities/ActivityTimespanCapability.js",
|
||||||
|
"depends": [ "$q" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "timespan",
|
||||||
|
"implementation": "capabilities/TimelineTimespanCapability.js",
|
||||||
|
"depends": [ "$q" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "utilization",
|
||||||
|
"implementation": "capabilities/UtilizationCapability.js",
|
||||||
|
"depends": [ "$q" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "graph",
|
||||||
|
"implementation": "capabilities/GraphCapability.js",
|
||||||
|
"depends": [ "$q" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cost",
|
||||||
|
"implementation": "capabilities/CostCapability.js"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"directives": [
|
||||||
|
{
|
||||||
|
"key": "warpSwimlaneDrop",
|
||||||
|
"implementation": "directives/WARPSwimlaneDrop.js",
|
||||||
|
"depends": [ "dndService" ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warpSwimlaneDrag",
|
||||||
|
"implementation": "directives/WARPSwimlaneDrag.js",
|
||||||
|
"depends": [ "dndService" ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"key": "warp.objectLoader",
|
||||||
|
"implementation": "services/ObjectLoader.js",
|
||||||
|
"depends": [ "$q" ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warp.resources": [
|
||||||
|
{
|
||||||
|
"key": "power",
|
||||||
|
"name": "Power",
|
||||||
|
"units": "watts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "comms",
|
||||||
|
"name": "Comms",
|
||||||
|
"units": "Kbps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "battery",
|
||||||
|
"name": "Battery State-of-Charge",
|
||||||
|
"units": "%"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
18
platform/features/timeline/res/templates/activity-gantt.html
Normal file
18
platform/features/timeline/res/templates/activity-gantt.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<div class="t-timeline-gantt l-timeline-gantt s-timeline-gantt"
|
||||||
|
title="{{model.name}}"
|
||||||
|
ng-controller="TimelineGanttController as gantt"
|
||||||
|
ng-style="{
|
||||||
|
left: gantt.left(timespan, parameters.scroll, parameters.toPixels) + 'px',
|
||||||
|
width: gantt.width(timespan, parameters.scroll, parameters.toPixels) + 'px'
|
||||||
|
}">
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<span class="s-activity-type ui-symbol">
|
||||||
|
{{type.getGlyph()}}
|
||||||
|
</span>
|
||||||
|
<span class="s-title">
|
||||||
|
{{model.name}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
@ -0,0 +1,62 @@
|
|||||||
|
<div class='form-control complex datetime'>
|
||||||
|
|
||||||
|
<div class='field-hints'>
|
||||||
|
<span class='hint time sm'>Days</span>
|
||||||
|
<span class='hint time sm'>Hours</span>
|
||||||
|
<span class='hint time sm'>Minutes</span>
|
||||||
|
<span class='hint time sm'>Seconds</span>
|
||||||
|
<span class='hint' ng-if="structure.options.length > 0">Time System</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-form name="mctControl">
|
||||||
|
<div class='fields' ng-controller="WARPDateTimeController">
|
||||||
|
<span class='field control time sm'>
|
||||||
|
<input type='text'
|
||||||
|
name='days'
|
||||||
|
min='0'
|
||||||
|
max='9999'
|
||||||
|
integer
|
||||||
|
ng-pattern="/\d+/"
|
||||||
|
ng-model='datetime.days'/>
|
||||||
|
</span>
|
||||||
|
<span class='field control time sm'>
|
||||||
|
<input type='text'
|
||||||
|
name='hour'
|
||||||
|
maxlength='2'
|
||||||
|
min='0'
|
||||||
|
max='23'
|
||||||
|
integer
|
||||||
|
ng-pattern='/\d+/'
|
||||||
|
ng-model="datetime.hours"/>
|
||||||
|
</span>
|
||||||
|
<span class='field control time sm'>
|
||||||
|
<input type='text'
|
||||||
|
name='min'
|
||||||
|
maxlength='2'
|
||||||
|
min='0'
|
||||||
|
max='59'
|
||||||
|
integer
|
||||||
|
ng-pattern='/\d+/'
|
||||||
|
ng-model="datetime.minutes"
|
||||||
|
ng-required='true'/>
|
||||||
|
</span>
|
||||||
|
<span class='field control time sm'>
|
||||||
|
<input type='text'
|
||||||
|
name='sec'
|
||||||
|
maxlength='2'
|
||||||
|
min='0'
|
||||||
|
max='59'
|
||||||
|
integer
|
||||||
|
ng-pattern='/\d+/'
|
||||||
|
ng-model="datetime.seconds"
|
||||||
|
ng-required='true'/>
|
||||||
|
</span>
|
||||||
|
<span ng-if="structure.options.length > 0"
|
||||||
|
class='field control'>
|
||||||
|
SET
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ng-form>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
13
platform/features/timeline/res/templates/legend-item.html
Normal file
13
platform/features/timeline/res/templates/legend-item.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!-- TO-DO: make legend item color-swatch dynamic -->
|
||||||
|
<span
|
||||||
|
class="legend-item s-legend-item"
|
||||||
|
title="{{ngModel.path}}{{ngModel.domainObject.getModel().name}}"
|
||||||
|
>
|
||||||
|
<span class="color-swatch"
|
||||||
|
ng-style="{ 'background-color': ngModel.color() }">
|
||||||
|
</span>
|
||||||
|
<span class="title-label">
|
||||||
|
<span class="l-parent-path">{{ngModel.path}}</span>
|
||||||
|
<span class="l-leaf-title">{{ngModel.domainObject.getModel().name}}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
@ -0,0 +1,16 @@
|
|||||||
|
<div class="l-title s-title">
|
||||||
|
{{parameters.title}}
|
||||||
|
</div>
|
||||||
|
<div class="l-graph-area">
|
||||||
|
<div class="l-labels-holder">
|
||||||
|
<div class="tick-label tick-label-y tick-label-1" style="top: 0;">
|
||||||
|
{{parameters.high}}
|
||||||
|
</div>
|
||||||
|
<div class="tick-label tick-label-y" style="top: 50%; margin-top: -0.5em; height: 1em;">
|
||||||
|
{{parameters.middle}}
|
||||||
|
</div>
|
||||||
|
<div class="tick-label tick-label-y" style="top: auto; bottom: 4px; height: 1em;">
|
||||||
|
{{parameters.low}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,13 @@
|
|||||||
|
<span ng-controller="TimelineGraphController as graphController">
|
||||||
|
<div class="t-graph l-graph" ng-repeat="graph in parameters.graphs">
|
||||||
|
<div class="l-graph-area l-canvas-holder">
|
||||||
|
<mct-chart draw="graph.drawingObject"></mct-chart>
|
||||||
|
</div>
|
||||||
|
<div class="t-graph-labels l-graph-labels">
|
||||||
|
<mct-include key="'timeline-resource-graph-labels'"
|
||||||
|
parameters="graphController.label(graph)"
|
||||||
|
ng-model="graph">
|
||||||
|
</mct-include>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
@ -0,0 +1,16 @@
|
|||||||
|
<div class="t-swimlane s-swimlane l-swimlane {{ngModel.activitystate}} {{ngModel.swimlanestate}}"
|
||||||
|
ng-class="{
|
||||||
|
exceeded: ngModel.exceeded(),
|
||||||
|
selected: ngModel.selected(swimlane),
|
||||||
|
'drop-into': ngModel.highlight(),
|
||||||
|
'drop-after': ngModel.highlightBottom()
|
||||||
|
}">
|
||||||
|
<div
|
||||||
|
class="l-cols"
|
||||||
|
ng-controller="TimelineTableController as tabularVal">
|
||||||
|
<span class="align-right l-col l-start">{{tabularVal.niceTime(ngModel.timespan().getStart())}}</span>
|
||||||
|
<span class="align-right l-col l-end">{{tabularVal.niceTime(ngModel.timespan().getEnd())}}</span>
|
||||||
|
<span class="align-right l-col l-duration">{{tabularVal.niceTime(ngModel.timespan().getDuration())}}</span>
|
||||||
|
<span class="l-col l-activity-modes"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,36 @@
|
|||||||
|
<div class="t-swimlane s-swimlane l-swimlane {{ngModel.activitystate}} {{ngModel.swimlanestate}}"
|
||||||
|
warp-swimlane-drop="ngModel"
|
||||||
|
ng-class="{
|
||||||
|
exceeded: ngModel.exceeded(),
|
||||||
|
selected: ngModel.selected(swimlane),
|
||||||
|
'drop-into': ngModel.highlight(),
|
||||||
|
'drop-after': ngModel.highlightBottom()
|
||||||
|
}">
|
||||||
|
<div class="l-cols">
|
||||||
|
<span class="l-col l-col-icon l-plot-resource"
|
||||||
|
ng-click="ngModel.toggleGraph()">
|
||||||
|
<span class="ui-symbol"
|
||||||
|
ng-show="ngModel.graph()">
|
||||||
|
é
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="l-col l-col-icon l-link">
|
||||||
|
<a class="ui-symbol"
|
||||||
|
target="_blank"
|
||||||
|
ng-href="{{ngModel.link()}}"
|
||||||
|
ng-if="ngModel.link().length > 0"
|
||||||
|
title="{{ngModel.link()}}"
|
||||||
|
>
|
||||||
|
è
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span class="l-col l-title"
|
||||||
|
ng-click="ngModel.select()"
|
||||||
|
ng-style="{ 'margin-left': 15 * ngModel.depth + 'px' }">
|
||||||
|
<mct-representation key="'label'"
|
||||||
|
mct-object="ngModel.domainObject"
|
||||||
|
warp-swimlane-drag="ngModel">
|
||||||
|
</mct-representation>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
18
platform/features/timeline/res/templates/ticks.html
Normal file
18
platform/features/timeline/res/templates/ticks.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<div class="t-header l-header s-header"
|
||||||
|
ng-controller="TimelineTickController as tick"
|
||||||
|
ng-style="{ width: parameters.fullWidth + 'px' }">
|
||||||
|
|
||||||
|
<div class="l-header-elem t-labels l-labels">
|
||||||
|
<div class="t-label l-label s-label"
|
||||||
|
ng-repeat="label in tick.labels(parameters.start, parameters.width, parameters.step, parameters.toMillis)"
|
||||||
|
ng-style="{ left: label.left + 'px' }">
|
||||||
|
{{label.text}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="t-ticks l-ticks s-ticks"
|
||||||
|
ng-style="{ 'background-size': parameters.step + 'px 100%' }">
|
||||||
|
</div>
|
||||||
|
<div class="t-ticks s-ticks l-subticks"
|
||||||
|
ng-style="{ 'background-size': (parameters.step / 40) + 'px 100%' }">
|
||||||
|
</div>
|
||||||
|
</div>
|
197
platform/features/timeline/res/templates/timeline.html
Normal file
197
platform/features/timeline/res/templates/timeline.html
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
<div class="s-timeline l-timeline-holder split-layout vertical"
|
||||||
|
ng-controller="TimelineController as timelineController">
|
||||||
|
|
||||||
|
<mct-split-pane anchor="left" class="abs" position="pane.x">
|
||||||
|
<!-- LEFT PANE: TABULAR AND RESOURCE LEGEND AREAS -->
|
||||||
|
<mct-split-pane anchor="bottom"
|
||||||
|
position="pane.y"
|
||||||
|
class="abs horizontal split-pane-component l-timeline-pane l-pane-l t-pane-v"
|
||||||
|
>
|
||||||
|
<!-- TOP PANE TABULAR AREA. ADD CLASS "hidden" FOR INTERIM NO-TABULAR DELIVERY -->
|
||||||
|
<div class="split-pane-component s-timeline-tabular l-timeline-pane t-pane-h l-pane-top">
|
||||||
|
<!-- TABULAR LEFT FIXED AREA -->
|
||||||
|
<div
|
||||||
|
class="t-pane-v l-pane-l l-tabular-l"
|
||||||
|
ng-if="true"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="t-header l-header s-header">
|
||||||
|
<div class="l-cols">
|
||||||
|
<span class="l-col l-col-icon l-plot-resource ui-symbol">é</span>
|
||||||
|
<span class="l-col l-col-icon l-col-link ui-symbol">è</span>
|
||||||
|
<span class="l-col l-title">Title</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="t-swimlanes-holder l-swimlanes-holder"
|
||||||
|
mct-scroll-y="scroll.y">
|
||||||
|
<mct-include key="'timeline-tabular-swimlane-cols-tree'"
|
||||||
|
ng-repeat="swimlane in timelineController.swimlanes()"
|
||||||
|
ng-model="swimlane">
|
||||||
|
</mct-include>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TABULAR RIGHT HORZ SCROLLING AREA -->
|
||||||
|
<div
|
||||||
|
class="t-pane-v l-pane-r l-tabular-r"
|
||||||
|
>
|
||||||
|
<div class="l-width">
|
||||||
|
<div class="t-header l-header s-header">
|
||||||
|
<div class="l-cols">
|
||||||
|
<span class="l-col l-start">Start</span>
|
||||||
|
<span class="l-col l-end">End</span>
|
||||||
|
<span class="l-col l-duration">Duration</span>
|
||||||
|
<span class="l-col l-activity-modes">Activity Modes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="t-swimlanes-holder l-swimlanes-holder"
|
||||||
|
mct-scroll-y="scroll.y">
|
||||||
|
<mct-include key="'timeline-tabular-swimlane-cols-data'"
|
||||||
|
ng-repeat="swimlane in timelineController.swimlanes()"
|
||||||
|
ng-model="swimlane">
|
||||||
|
</mct-include>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HORZ SPLITTER -->
|
||||||
|
<mct-splitter></mct-splitter>
|
||||||
|
|
||||||
|
<!-- BOTTOM PANE RESOURCE LEGEND -->
|
||||||
|
<div class="split-pane-component abs l-timeline-pane t-pane-h l-pane-btm s-timeline-resource-legend l-timeline-resource-legend">
|
||||||
|
<div class="l-title s-title">{{ngModel.title}}Resource Graph Legend</div>
|
||||||
|
<div class="l-legend-items legend">
|
||||||
|
<mct-include key="'timeline-legend-item'"
|
||||||
|
ng-repeat="swimlane in timelineController.swimlanes()"
|
||||||
|
ng-model="swimlane"
|
||||||
|
ng-show="swimlane.graph()">
|
||||||
|
</mct-include>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mct-split-pane>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- MAIN VERTICAL SPLITTER -->
|
||||||
|
<mct-splitter></mct-splitter>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- RIGHT PANE: GANTT AND RESOURCE PLOTS -->
|
||||||
|
<span ng-controller="TimelineZoomController as zoomController" class="abs">
|
||||||
|
<mct-split-pane anchor="bottom"
|
||||||
|
position="pane.y"
|
||||||
|
class="abs split-pane-component l-timeline-pane l-pane-r t-pane-v"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- TOP PANE GANTT BARS -->
|
||||||
|
<div class="split-pane-component l-timeline-pane t-pane-h l-pane-top t-timeline-gantt l-timeline-gantt s-timeline-gantt"
|
||||||
|
>
|
||||||
|
<div class="l-hover-btns-holder s-hover-btns-holder t-btns-zoom">
|
||||||
|
<a class="t-btn l-btn s-btn s-icon-btn"
|
||||||
|
ng-click="zoomController.zoom(-1)"
|
||||||
|
ng-show="true"
|
||||||
|
title="Zoom in">
|
||||||
|
<span class="ui-symbol icon zoom-in">X</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="t-btn l-btn s-btn s-icon-btn"
|
||||||
|
ng-click="zoomController.zoom(1)"
|
||||||
|
ng-show="true"
|
||||||
|
title="Zoom out">
|
||||||
|
<span class="ui-symbol icon zoom-out">Y</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow: hidden; position: absolute; left: 0; top: 0; right: 0; height: 30px;"
|
||||||
|
mct-scroll-x="scroll.x">
|
||||||
|
<mct-include key="'timeline-ticks'"
|
||||||
|
parameters="{
|
||||||
|
fullWidth: zoomController.toPixels(zoomController.duration()),
|
||||||
|
start: scroll.x,
|
||||||
|
width: scroll.width,
|
||||||
|
step: zoomController.toPixels(zoomController.zoom()),
|
||||||
|
toMillis: zoomController.toMillis
|
||||||
|
}">
|
||||||
|
</mct-include>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TO-DO:
|
||||||
|
Make this control y-scroll of both .t-swimlanes-holder elements in TOP PANE TABULAR AREA
|
||||||
|
-->
|
||||||
|
<div class="t-swimlanes-holder l-swimlanes-holder"
|
||||||
|
mct-scroll-x="scroll.x"
|
||||||
|
mct-scroll-y="scroll.y">
|
||||||
|
<div class="l-width-control"
|
||||||
|
ng-style="{ width: timelineController.width(zoomController) + 'px' }">
|
||||||
|
<div class="t-swimlane s-swimlane l-swimlane"
|
||||||
|
ng-repeat="swimlane in timelineController.swimlanes()"
|
||||||
|
ng-class="{
|
||||||
|
exceeded: swimlane.exceeded(),
|
||||||
|
selected: selection.selected(swimlane),
|
||||||
|
'drop-into': swimlane.highlight(),
|
||||||
|
'drop-after': swimlane.highlightBottom()
|
||||||
|
}"
|
||||||
|
ng-click="selection.select(swimlane)"
|
||||||
|
warp-swimlane-drop="swimlane">
|
||||||
|
|
||||||
|
<mct-representation key="'warp.gantt'"
|
||||||
|
mct-object="swimlane.domainObject"
|
||||||
|
parameters="{
|
||||||
|
scroll: scroll,
|
||||||
|
toPixels: zoomController.toPixels
|
||||||
|
}">
|
||||||
|
</mct-representation>
|
||||||
|
|
||||||
|
<span ng-if="selection.selected(swimlane)">
|
||||||
|
<span ng-repeat="handle in timelineController.handles()"
|
||||||
|
ng-style="handle.style(zoomController)"
|
||||||
|
style="position: absolute; top: 0px; bottom: 0px;"
|
||||||
|
class="handle"
|
||||||
|
ng-class="{ start: $index === 0, mid: $index === 1, end: $index > 1 }"
|
||||||
|
mct-drag-down="handle.begin()"
|
||||||
|
mct-drag="handle.drag(delta[0], zoomController); timelineController.refresh()"
|
||||||
|
mct-drag-up="handle.finish()">
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HORZ SPLITTER -->
|
||||||
|
<mct-splitter></mct-splitter>
|
||||||
|
|
||||||
|
<!-- BOTTOM PANE RESOURCE GRAPHS AND RIGHT PANE HORIZONTAL SCROLL CONTROL -->
|
||||||
|
<div class="split-pane-component l-timeline-resource-graph l-timeline-pane t-pane-h l-pane-btm">
|
||||||
|
|
||||||
|
<div class="l-graphs-holder"
|
||||||
|
mct-resize="scroll.width = bounds.width">
|
||||||
|
|
||||||
|
<!-- TO-DO: Make this control y-scroll of .t-graph-labels-holder -->
|
||||||
|
<div class="t-graphs l-graphs">
|
||||||
|
<mct-include key="'timeline-resource-graphs'"
|
||||||
|
parameters="{
|
||||||
|
origin: zoomController.toMillis(scroll.x),
|
||||||
|
duration: zoomController.toMillis(scroll.width),
|
||||||
|
graphs: timelineController.graphs()
|
||||||
|
}">
|
||||||
|
</mct-include>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- TO-DO: Make this control x-scroll of .t-timeline-gantt -->
|
||||||
|
<div mct-scroll-x="scroll.x"
|
||||||
|
class="t-pane-r-scroll-h-control l-scroll-control s-scroll-control">
|
||||||
|
<div class="l-width-control"
|
||||||
|
ng-style="{ width: timelineController.width(zoomController) + 'px' }">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mct-split-pane>
|
||||||
|
</span>
|
||||||
|
</mct-split-pane>
|
||||||
|
</div>
|
6
platform/features/timeline/res/templates/values.html
Normal file
6
platform/features/timeline/res/templates/values.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<ul ng-controller="ActivityModeValuesController as controller" class="cols cols-2-ff properties">
|
||||||
|
<li ng-repeat="(key, value) in cost" class="l-row s-row">
|
||||||
|
<span class="col col-100px s-title">{{controller.metadata(key).name}}</span>
|
||||||
|
<span class="col s-value">{{value}} {{controller.metadata(key).units}}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
11
platform/features/timeline/src/TimelineConstants.js
Normal file
11
platform/features/timeline/src/TimelineConstants.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines constant values for use in timeline view.
|
||||||
|
*/
|
||||||
|
define({
|
||||||
|
// Pixel width of start/end handles
|
||||||
|
HANDLE_WIDTH: 32,
|
||||||
|
// Pixel tolerance for snapping behavior
|
||||||
|
SNAP_WIDTH: 16
|
||||||
|
});
|
57
platform/features/timeline/src/TimelineFormatter.js
Normal file
57
platform/features/timeline/src/TimelineFormatter.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Conversion factors from time units to milliseconds
|
||||||
|
var SECONDS = 1000,
|
||||||
|
MINUTES = SECONDS * 60,
|
||||||
|
HOURS = MINUTES * 60,
|
||||||
|
DAYS = HOURS * 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatters for durations shown in a timeline view.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineFormatter() {
|
||||||
|
|
||||||
|
// Format a numeric value to a string with some number of digits
|
||||||
|
function formatValue(value, digits) {
|
||||||
|
var v = value.toString(10);
|
||||||
|
// Pad with zeroes
|
||||||
|
while (v.length < digits) {
|
||||||
|
v = "0" + v;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format duration to string
|
||||||
|
function formatDuration(duration) {
|
||||||
|
var days = Math.floor(duration / DAYS),
|
||||||
|
hours = Math.floor(duration / HOURS) % 24,
|
||||||
|
minutes = Math.floor(duration / MINUTES) % 60,
|
||||||
|
seconds = Math.floor(duration / SECONDS) % 60,
|
||||||
|
millis = Math.floor(duration) % 1000;
|
||||||
|
|
||||||
|
return formatValue(days, 3) + " " +
|
||||||
|
formatValue(hours, 2) + ":" +
|
||||||
|
formatValue(minutes, 2) + ":" +
|
||||||
|
formatValue(seconds, 2) + "." +
|
||||||
|
formatValue(millis, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Format the provided duration.
|
||||||
|
* @param {number} duration duration, in milliseconds
|
||||||
|
* @returns {string} displayable representation of duration
|
||||||
|
*/
|
||||||
|
format: formatDuration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineFormatter;
|
||||||
|
}
|
||||||
|
);
|
100
platform/features/timeline/src/capabilities/ActivityTimespan.js
Normal file
100
platform/features/timeline/src/capabilities/ActivityTimespan.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the time span of an activity object.
|
||||||
|
* @param model the activity's object model
|
||||||
|
*/
|
||||||
|
function ActivityTimespan(model, mutation) {
|
||||||
|
// Get the start time for this timeline
|
||||||
|
function getStart() {
|
||||||
|
return model.start.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the end time for this timeline
|
||||||
|
function getEnd() {
|
||||||
|
return model.start.timestamp + model.duration.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the duration of this timeline
|
||||||
|
function getDuration() {
|
||||||
|
return model.duration.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the epoch used by this timeline
|
||||||
|
function getEpoch() {
|
||||||
|
return model.start.epoch; // Surface elapsed time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the start time associated with this object
|
||||||
|
function setStart(value) {
|
||||||
|
var end = getEnd();
|
||||||
|
mutation.mutate(function (model) {
|
||||||
|
model.start.timestamp = Math.max(value, 0);
|
||||||
|
// Update duration to keep end time
|
||||||
|
model.duration.timestamp = Math.max(end - value, 0);
|
||||||
|
}, model.modified);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the duration associated with this object
|
||||||
|
function setDuration(value) {
|
||||||
|
mutation.mutate(function (model) {
|
||||||
|
model.duration.timestamp = Math.max(value, 0);
|
||||||
|
}, model.modified);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the end time associated with this object
|
||||||
|
function setEnd(value) {
|
||||||
|
var start = getStart();
|
||||||
|
mutation.mutate(function (model) {
|
||||||
|
model.duration.timestamp = Math.max(value - start, 0);
|
||||||
|
}, model.modified);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the start time, in milliseconds relative to the epoch.
|
||||||
|
* @returns {number} the start time
|
||||||
|
*/
|
||||||
|
getStart: getStart,
|
||||||
|
/**
|
||||||
|
* Get the duration, in milliseconds.
|
||||||
|
* @returns {number} the duration
|
||||||
|
*/
|
||||||
|
getDuration: getDuration,
|
||||||
|
/**
|
||||||
|
* Get the end time, in milliseconds relative to the epoch.
|
||||||
|
* @returns {number} the end time
|
||||||
|
*/
|
||||||
|
getEnd: getEnd,
|
||||||
|
/**
|
||||||
|
* Set the start time, in milliseconds relative to the epoch.
|
||||||
|
* @param {number} the new value
|
||||||
|
*/
|
||||||
|
setStart: setStart,
|
||||||
|
/**
|
||||||
|
* Set the duration, in milliseconds.
|
||||||
|
* @param {number} the new value
|
||||||
|
*/
|
||||||
|
setDuration: setDuration,
|
||||||
|
/**
|
||||||
|
* Set the end time, in milliseconds relative to the epoch.
|
||||||
|
* @param {number} the new value
|
||||||
|
*/
|
||||||
|
setEnd: setEnd,
|
||||||
|
/**
|
||||||
|
* Get a string identifying the reference epoch used for
|
||||||
|
* start and end times.
|
||||||
|
* @returns {string} the epoch
|
||||||
|
*/
|
||||||
|
getEpoch: getEpoch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActivityTimespan;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,42 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./ActivityTimespan'],
|
||||||
|
function (ActivityTimespan) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the `warp.timespan` capability for Activity objects.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param $q Angular's $q, for promise-handling
|
||||||
|
* @param {DomainObject} domainObject the Activity
|
||||||
|
*/
|
||||||
|
function ActivityTimespanCapability($q, domainObject) {
|
||||||
|
// Promise time span
|
||||||
|
function promiseTimeSpan() {
|
||||||
|
return $q.when(new ActivityTimespan(
|
||||||
|
domainObject.getModel(),
|
||||||
|
domainObject.getCapability('mutation')
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the time span (start, end, duration) of this activity.
|
||||||
|
* @returns {Promise.<ActivityTimespan>} the time span of
|
||||||
|
* this activity
|
||||||
|
*/
|
||||||
|
invoke: promiseTimeSpan
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only applies to timeline objects
|
||||||
|
ActivityTimespanCapability.appliesTo = function (model) {
|
||||||
|
return model && (model.type === 'warp.activity');
|
||||||
|
};
|
||||||
|
|
||||||
|
return ActivityTimespanCapability;
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,31 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides data to populate resource graphs associated
|
||||||
|
* with activities in a timeline view.
|
||||||
|
* This is a placeholder until WTD-918.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function ActivityUtilization() {
|
||||||
|
return {
|
||||||
|
getPointCount: function () {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
getDomainValue: function (index) {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
getRangeValue: function (index) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActivityUtilization;
|
||||||
|
}
|
||||||
|
|
||||||
|
);
|
@ -0,0 +1,56 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposes costs associated with a subsystem mode.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function CostCapability(domainObject) {
|
||||||
|
var model = domainObject.getModel();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get a list of resource types which have associated
|
||||||
|
* costs for this object. Returned values are machine-readable
|
||||||
|
* keys, and should be paired with external metadata for
|
||||||
|
* presentation (see category of extension `warp.resources`).
|
||||||
|
* @returns {string[]} resource types
|
||||||
|
*/
|
||||||
|
resources: function () {
|
||||||
|
return Object.keys(model.resources || {}).sort();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the cost associated with a resource of an identified
|
||||||
|
* type (typically, one of the types reported from a
|
||||||
|
* `resources` call.)
|
||||||
|
* @param {string} key the resource type
|
||||||
|
* @returns {number} the associated cost
|
||||||
|
*/
|
||||||
|
cost: function (key) {
|
||||||
|
return (model.resources || {})[key] || 0;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get an object containing key-value pairs describing
|
||||||
|
* resource utilization as described by this object.
|
||||||
|
* Keys are resource types; values are levels of associated
|
||||||
|
* resource utilization.
|
||||||
|
* @returns {object} resource utilizations
|
||||||
|
*/
|
||||||
|
invoke: function () {
|
||||||
|
return model.resources || {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only applies to subsystem modes.
|
||||||
|
CostCapability.appliesTo = function (model) {
|
||||||
|
return (model || {}).type === 'warp.mode';
|
||||||
|
};
|
||||||
|
|
||||||
|
return CostCapability;
|
||||||
|
}
|
||||||
|
);
|
134
platform/features/timeline/src/capabilities/CumulativeGraph.js
Normal file
134
platform/features/timeline/src/capabilities/CumulativeGraph.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide points for a cumulative resource summary graph, using
|
||||||
|
* a provided instantaneous resource summary graph.
|
||||||
|
*
|
||||||
|
* @param {ResourceGraph} graph the resource graph
|
||||||
|
* @param {number} minimum the minimum allowable level
|
||||||
|
* @param {number} maximum the maximum allowable level
|
||||||
|
* @param {number} initial the initial state of the resource
|
||||||
|
* @param {number} rate the rate at which one unit of instantaneous
|
||||||
|
* utilization changes the available level in one unit
|
||||||
|
* of domain values (that is, per millisecond)
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function CumulativeGraph(graph, minimum, maximum, initial, rate) {
|
||||||
|
var values;
|
||||||
|
|
||||||
|
// Calculate the domain value at which a line starting at
|
||||||
|
// (domain, range) and proceeding with the specified slope
|
||||||
|
// will have the specified range value.
|
||||||
|
function intercept(domain, range, slope, value) {
|
||||||
|
// value = slope * (intercept - domain) + range
|
||||||
|
// value - range = slope * ...
|
||||||
|
// intercept - domain = (value - range) / slope
|
||||||
|
// intercept = domain + (value - range) / slope
|
||||||
|
return domain + (value - range) / slope;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the data values
|
||||||
|
function initializeValues() {
|
||||||
|
var values = [],
|
||||||
|
slope = 0,
|
||||||
|
previous = 0,
|
||||||
|
i;
|
||||||
|
|
||||||
|
// Add a point (or points, if needed) reaching to the provided
|
||||||
|
// domain and/or range value
|
||||||
|
function addPoint(domain, range) {
|
||||||
|
var previous = values[values.length - 1],
|
||||||
|
delta = domain - previous.domain, // time delta
|
||||||
|
change = delta * slope * rate, // change
|
||||||
|
next = previous.range + change;
|
||||||
|
|
||||||
|
// Crop to minimum boundary...
|
||||||
|
if (next < minimum) {
|
||||||
|
values.push({
|
||||||
|
domain: intercept(
|
||||||
|
previous.domain,
|
||||||
|
previous.range,
|
||||||
|
slope * rate,
|
||||||
|
minimum
|
||||||
|
),
|
||||||
|
range: minimum
|
||||||
|
});
|
||||||
|
next = minimum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and maximum boundary
|
||||||
|
if (next > maximum) {
|
||||||
|
values.push({
|
||||||
|
domain: intercept(
|
||||||
|
previous.domain,
|
||||||
|
previous.range,
|
||||||
|
slope * rate,
|
||||||
|
maximum
|
||||||
|
),
|
||||||
|
range: maximum
|
||||||
|
});
|
||||||
|
next = maximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new data value
|
||||||
|
if (delta > 0) {
|
||||||
|
values.push({ domain: domain, range: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
slope = range;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push({ domain: 0, range: initial });
|
||||||
|
|
||||||
|
for (i = 0; i < graph.getPointCount(); i += 1) {
|
||||||
|
addPoint(graph.getDomainValue(i), graph.getRangeValue(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToPercent(point) {
|
||||||
|
point.range = 100 *
|
||||||
|
(point.range - minimum) / (maximum - minimum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate cumulative values...
|
||||||
|
values = initializeValues();
|
||||||
|
|
||||||
|
// ...and convert to percentages.
|
||||||
|
values.forEach(convertToPercent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the total number of points in this graph.
|
||||||
|
* @returns {number} the total number of points
|
||||||
|
*/
|
||||||
|
getPointCount: function () {
|
||||||
|
return values.length;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the domain value (timestamp) for a point in this graph.
|
||||||
|
* @returns {number} the domain value
|
||||||
|
*/
|
||||||
|
getDomainValue: function (index) {
|
||||||
|
return values[index].domain;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the range value (utilization level) for a point in
|
||||||
|
* this graph.
|
||||||
|
* @returns {number} the range value
|
||||||
|
*/
|
||||||
|
getRangeValue: function (index) {
|
||||||
|
return values[index].range;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return CumulativeGraph;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,78 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./ResourceGraph', './CumulativeGraph'],
|
||||||
|
function (ResourceGraph, CumulativeGraph) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the `graph` capability for Timeline and
|
||||||
|
* Activity objects.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param {DomainObject} domainObject the Timeline or Activity
|
||||||
|
*/
|
||||||
|
function GraphCapability($q, domainObject) {
|
||||||
|
|
||||||
|
|
||||||
|
// Build graphs for this group of utilizations
|
||||||
|
function buildGraphs(utilizations) {
|
||||||
|
var utilizationMap = {},
|
||||||
|
result = {};
|
||||||
|
|
||||||
|
// Bucket utilizations by type
|
||||||
|
utilizations.forEach(function (u) {
|
||||||
|
var k = u.key;
|
||||||
|
utilizationMap[k] = utilizationMap[k] || [];
|
||||||
|
utilizationMap[k].push(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ...then convert to graphs
|
||||||
|
Object.keys(utilizationMap).forEach(function (k) {
|
||||||
|
result[k] = new ResourceGraph(utilizationMap[k]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add battery state of charge
|
||||||
|
if (domainObject.getModel().type === 'warp.timeline' &&
|
||||||
|
result.power &&
|
||||||
|
domainObject.getModel().capacity > 0) {
|
||||||
|
|
||||||
|
result.battery = new CumulativeGraph(
|
||||||
|
result.power,
|
||||||
|
0,
|
||||||
|
domainObject.getModel().capacity, // Watts
|
||||||
|
domainObject.getModel().capacity,
|
||||||
|
1 / 3600000 // millis-to-hour (since units are watt-hours)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get resource graphs associated with this object.
|
||||||
|
* This is given as a promise for key-value pairs,
|
||||||
|
* where keys are resource types and values are graph
|
||||||
|
* objects.
|
||||||
|
* @returns {Promise} a promise for resource graphs
|
||||||
|
*/
|
||||||
|
invoke: function () {
|
||||||
|
return $q.when(
|
||||||
|
domainObject.useCapability('utilization') || []
|
||||||
|
).then(buildGraphs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only applies to timeline objects
|
||||||
|
GraphCapability.appliesTo = function (model) {
|
||||||
|
return model &&
|
||||||
|
((model.type === 'warp.timeline') ||
|
||||||
|
(model.type === 'warp.activity'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return GraphCapability;
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
128
platform/features/timeline/src/capabilities/ResourceGraph.js
Normal file
128
platform/features/timeline/src/capabilities/ResourceGraph.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Utility function to copy an array, sorted by a specific field
|
||||||
|
function sort(array, field) {
|
||||||
|
return array.slice().sort(function (a, b) {
|
||||||
|
return a[field] - b[field];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides data to populate resource graphs associated
|
||||||
|
* with timelines and activities.
|
||||||
|
* @param {Array} utilizations resource utilizations
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function ResourceGraph(utilizations) {
|
||||||
|
// Overview of algorithm here:
|
||||||
|
// * Goal: Have a list of time/value pairs which represents
|
||||||
|
// points along a stepped chart of resource utilization.
|
||||||
|
// Each change (stepping up or down) should have two points,
|
||||||
|
// at the bottom and top of the step respectively.
|
||||||
|
// * Step 1: Prepare two lists of utilizations sorted by start
|
||||||
|
// and end times. The "starts" will become step-ups, the
|
||||||
|
// "ends" will become step-downs.
|
||||||
|
// * Step 2: Initialize empty arrays for results, and a variable
|
||||||
|
// for the current utilization level.
|
||||||
|
// * Step 3: While there are still start or end times to add...
|
||||||
|
// * Step 3a: Determine whether the next change should be a
|
||||||
|
// step-up (start) or step-down (end) based on which of the
|
||||||
|
// next start/end times comes next (note that starts and ends
|
||||||
|
// are both sorted, so we look at the head of the array.)
|
||||||
|
// * Step 3b: Pull the next start or end (per previous decision)
|
||||||
|
// and convert it to a time-delta pair, negating if it's an
|
||||||
|
// end time (to step down or "un-step")
|
||||||
|
// * Step 3c: Add a point at the new time and the current
|
||||||
|
// running total (first point in the step, before the change)
|
||||||
|
// then increment the running total and add a new point
|
||||||
|
// (second point in the step, after the change)
|
||||||
|
// * Step 4: Filter out unnecessary points (if two activities
|
||||||
|
// run up against each other, there will be a zero-duration
|
||||||
|
// spike if we don't filter out the extra points from their
|
||||||
|
// start/end times.)
|
||||||
|
//
|
||||||
|
var starts = sort(utilizations, "start"),
|
||||||
|
ends = sort(utilizations, "end"),
|
||||||
|
values = [],
|
||||||
|
running = 0;
|
||||||
|
|
||||||
|
// If there are sequences of points with the same timestamp,
|
||||||
|
// allow only the first and last.
|
||||||
|
function filterPoint(value, index, values) {
|
||||||
|
// Allow the first or last point as a base case; aside from
|
||||||
|
// that, allow only points that have different timestamps
|
||||||
|
// from their predecessor or successor.
|
||||||
|
return (index === 0) || (index === values.length - 1) ||
|
||||||
|
(value.domain !== values[index - 1].domain) ||
|
||||||
|
(value.domain !== values[index + 1].domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a step up or down (Step 3c above)
|
||||||
|
function addDelta(time, delta) {
|
||||||
|
values.push({ domain: time, range: running });
|
||||||
|
running += delta;
|
||||||
|
values.push({ domain: time, range: running });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a start time (Step 3b above)
|
||||||
|
function addStart() {
|
||||||
|
var next = starts.shift();
|
||||||
|
addDelta(next.start, next.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an end time (Step 3b above)
|
||||||
|
function addEnd() {
|
||||||
|
var next = ends.shift();
|
||||||
|
addDelta(next.end, -next.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide whether next step should correspond to a start or
|
||||||
|
// an end. (Step 3c above)
|
||||||
|
function pickStart() {
|
||||||
|
return ends.length < 1 ||
|
||||||
|
(starts.length > 0 && starts[0].start <= ends[0].end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build up start/end arrays (step 3 above)
|
||||||
|
while (starts.length > 0 || ends.length > 0) {
|
||||||
|
(pickStart() ? addStart : addEnd)();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out excess points
|
||||||
|
values = values.filter(filterPoint);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the total number of points in this graph.
|
||||||
|
* @returns {number} the total number of points
|
||||||
|
*/
|
||||||
|
getPointCount: function () {
|
||||||
|
return values.length;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the domain value (timestamp) for a point in this graph.
|
||||||
|
* @returns {number} the domain value
|
||||||
|
*/
|
||||||
|
getDomainValue: function (index) {
|
||||||
|
return values[index].domain;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the range value (utilization level) for a point in
|
||||||
|
* this graph.
|
||||||
|
* @returns {number} the range value
|
||||||
|
*/
|
||||||
|
getRangeValue: function (index) {
|
||||||
|
return values[index].range;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResourceGraph;
|
||||||
|
}
|
||||||
|
|
||||||
|
);
|
105
platform/features/timeline/src/capabilities/TimelineTimespan.js
Normal file
105
platform/features/timeline/src/capabilities/TimelineTimespan.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the time span of a timeline object.
|
||||||
|
* @param model the timeline's object model
|
||||||
|
* @param {Timespan[]} time spans of contained activities
|
||||||
|
*/
|
||||||
|
function TimelineTimespan(model, mutation, timespans) {
|
||||||
|
// Get the start time for this timeline
|
||||||
|
function getStart() {
|
||||||
|
return model.start.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the end time for another time span
|
||||||
|
function getTimespanEnd(timespan) {
|
||||||
|
return timespan.getEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapper for Math.max; used for max-finding of end time
|
||||||
|
function max(a, b) {
|
||||||
|
return Math.max(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the end time for this timeline
|
||||||
|
function getEnd() {
|
||||||
|
return timespans.map(getTimespanEnd).reduce(max, getStart());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the duration of this timeline
|
||||||
|
function getDuration() {
|
||||||
|
return getEnd() - getStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the start time associated with this object
|
||||||
|
function setStart(value) {
|
||||||
|
mutation.mutate(function (model) {
|
||||||
|
model.start.timestamp = Math.max(value, 0);
|
||||||
|
}, model.modified);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the duration associated with this object
|
||||||
|
function setDuration(value) {
|
||||||
|
// No-op; duration is implicit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the end time associated with this object
|
||||||
|
function setEnd(value) {
|
||||||
|
// No-op; end time is implicit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the epoch used by this timeline
|
||||||
|
function getEpoch() {
|
||||||
|
return model.start.epoch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the start time, in milliseconds relative to the epoch.
|
||||||
|
* @returns {number} the start time
|
||||||
|
*/
|
||||||
|
getStart: getStart,
|
||||||
|
/**
|
||||||
|
* Get the duration, in milliseconds.
|
||||||
|
* @returns {number} the duration
|
||||||
|
*/
|
||||||
|
getDuration: getDuration,
|
||||||
|
/**
|
||||||
|
* Get the end time, in milliseconds relative to the epoch.
|
||||||
|
* @returns {number} the end time
|
||||||
|
*/
|
||||||
|
getEnd: getEnd,
|
||||||
|
/**
|
||||||
|
* Set the start time, in milliseconds relative to the epoch.
|
||||||
|
* @param {number} the new value
|
||||||
|
*/
|
||||||
|
setStart: setStart,
|
||||||
|
/**
|
||||||
|
* Set the duration, in milliseconds. Timeline durations are
|
||||||
|
* implicit, so this is actually a no-op
|
||||||
|
* @param {number} the new value
|
||||||
|
*/
|
||||||
|
setDuration: setDuration,
|
||||||
|
/**
|
||||||
|
* Set the end time, in milliseconds. Timeline end times are
|
||||||
|
* implicit, so this is actually a no-op.
|
||||||
|
* @param {number} the new value
|
||||||
|
*/
|
||||||
|
setEnd: setEnd,
|
||||||
|
/**
|
||||||
|
* Get a string identifying the reference epoch used for
|
||||||
|
* start and end times.
|
||||||
|
* @returns {string} the epoch
|
||||||
|
*/
|
||||||
|
getEpoch: getEpoch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineTimespan;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,68 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./TimelineTimespan'],
|
||||||
|
function (TimelineTimespan) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the `timespan` capability for Timeline objects.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param $q Angular's $q, for promise-handling
|
||||||
|
* @param {DomainObject} domainObject the Timeline
|
||||||
|
*/
|
||||||
|
function TimelineTimespanCapability($q, domainObject) {
|
||||||
|
// Check if a capability is defin
|
||||||
|
|
||||||
|
// Look up a child object's time span
|
||||||
|
function lookupTimeSpan(childObject) {
|
||||||
|
return childObject.useCapability('timespan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a child object exposes a time span
|
||||||
|
function hasTimeSpan(childObject) {
|
||||||
|
return childObject.hasCapability('timespan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantiate a time span bounding other time spans
|
||||||
|
function giveTimeSpan(timespans) {
|
||||||
|
return new TimelineTimespan(
|
||||||
|
domainObject.getModel(),
|
||||||
|
domainObject.getCapability('mutation'),
|
||||||
|
timespans
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a time span object that fits all children
|
||||||
|
function buildTimeSpan(childObjects) {
|
||||||
|
return $q.all(
|
||||||
|
childObjects.filter(hasTimeSpan).map(lookupTimeSpan)
|
||||||
|
).then(giveTimeSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise
|
||||||
|
function promiseTimeSpan() {
|
||||||
|
return domainObject.useCapability('composition')
|
||||||
|
.then(buildTimeSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the time span (start, end, duration) of this timeline.
|
||||||
|
* @returns {Promise.<TimelineTimespan>} the time span of
|
||||||
|
* this timeline
|
||||||
|
*/
|
||||||
|
invoke: promiseTimeSpan
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only applies to timeline objects
|
||||||
|
TimelineTimespanCapability.appliesTo = function (model) {
|
||||||
|
return model && (model.type === 'warp.timeline');
|
||||||
|
};
|
||||||
|
|
||||||
|
return TimelineTimespanCapability;
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,31 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides data to populate resource graphs associated
|
||||||
|
* with timelines in a timeline view.
|
||||||
|
* This is a placeholder until WTD-918.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineUtilization() {
|
||||||
|
return {
|
||||||
|
getPointCount: function () {
|
||||||
|
return 1000;
|
||||||
|
},
|
||||||
|
getDomainValue: function (index) {
|
||||||
|
return 60000 * index;
|
||||||
|
},
|
||||||
|
getRangeValue: function (index) {
|
||||||
|
return Math.sin(index) * (index % 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineUtilization;
|
||||||
|
}
|
||||||
|
|
||||||
|
);
|
@ -0,0 +1,198 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide the resource utilization over time for a timeline
|
||||||
|
* or activity object. A utilization is presented as an object
|
||||||
|
* with four properties:
|
||||||
|
* * `key`: The resource being utilized.
|
||||||
|
* * `value`: The numeric utilization of that resource.
|
||||||
|
* * `start`: The start time of the resource's utilization.
|
||||||
|
* * `end`: The duration of this resource's utilization.
|
||||||
|
* * `epoch`: The epoch to which `start` is relative.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function UtilizationCapability($q, domainObject) {
|
||||||
|
|
||||||
|
// Utility function for array reduction
|
||||||
|
function concatenate(a, b) {
|
||||||
|
return (a || []).concat(b || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether an element in an array looks unique (for below)
|
||||||
|
function unique(element, index, array) {
|
||||||
|
return (index === 0) || (array[index - 1] !== element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to ensure sorted array is all unique
|
||||||
|
function uniquify(array) {
|
||||||
|
return array.filter(unique);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function for sorting strings arrays
|
||||||
|
function sort(array) {
|
||||||
|
return array.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine into one big array
|
||||||
|
function flatten(arrayOfArrays) {
|
||||||
|
return arrayOfArrays.reduce(concatenate, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise the objects contained by this timeline/activity
|
||||||
|
function promiseComposition() {
|
||||||
|
return $q.when(domainObject.useCapability('composition') || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise all subsystem modes associated with this object
|
||||||
|
function promiseModes() {
|
||||||
|
var relationship = domainObject.getCapability('relationship'),
|
||||||
|
modes = relationship && relationship.getRelatedObjects('modes');
|
||||||
|
return $q.when(modes || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise the utilization which results directly from this object
|
||||||
|
function promiseInternalUtilization() {
|
||||||
|
var utilizations = {};
|
||||||
|
|
||||||
|
// Record the cost of a given activity mode
|
||||||
|
function addUtilization(mode) {
|
||||||
|
var cost = mode.getCapability('cost');
|
||||||
|
if (cost) {
|
||||||
|
cost.resources().forEach(function (k) {
|
||||||
|
utilizations[k] = utilizations[k] || 0;
|
||||||
|
utilizations[k] += cost.cost(k);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record costs for these modes
|
||||||
|
function addUtilizations(modes) {
|
||||||
|
modes.forEach(addUtilization);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up start/end times for this object
|
||||||
|
function lookupTimespan() {
|
||||||
|
return domainObject.useCapability('timespan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide the result
|
||||||
|
function giveResult(timespan) {
|
||||||
|
// Convert to utilization objects
|
||||||
|
return Object.keys(utilizations).sort().map(function (k) {
|
||||||
|
return {
|
||||||
|
key: k,
|
||||||
|
value: utilizations[k],
|
||||||
|
start: timespan.getStart(),
|
||||||
|
end: timespan.getEnd(),
|
||||||
|
epoch: timespan.getEpoch()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return promiseModes()
|
||||||
|
.then(addUtilizations)
|
||||||
|
.then(lookupTimespan)
|
||||||
|
.then(giveResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up a specific object's resource utilization
|
||||||
|
function lookupUtilization(domainObject) {
|
||||||
|
return domainObject.useCapability('utilization');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up a specific object's resource utilization keys
|
||||||
|
function lookupUtilizationResources(domainObject) {
|
||||||
|
var utilization = domainObject.getCapability('utilization');
|
||||||
|
return utilization && utilization.resources();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise a consolidated list of resource utilizations
|
||||||
|
function mapUtilization(objects) {
|
||||||
|
return $q.all(objects.map(lookupUtilization))
|
||||||
|
.then(flatten);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise a consolidated list of resource utilization keys
|
||||||
|
function mapUtilizationResources(objects) {
|
||||||
|
return $q.all(objects.map(lookupUtilizationResources))
|
||||||
|
.then(flatten);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise utilization associated with contained objects
|
||||||
|
function promiseExternalUtilization() {
|
||||||
|
// Get the composition, then consolidate their utilizations
|
||||||
|
return promiseComposition().then(mapUtilization);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get resource keys for this mode
|
||||||
|
function getModeKeys(mode) {
|
||||||
|
var cost = mode.getCapability('cost');
|
||||||
|
return cost ? cost.resources() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the above (for use in below)
|
||||||
|
function mapModeKeys(modes) {
|
||||||
|
return modes.map(getModeKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise identifiers for resources associated with modes
|
||||||
|
function promiseInternalKeys() {
|
||||||
|
return promiseModes().then(mapModeKeys).then(flatten);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise identifiers for resources associated with modes
|
||||||
|
function promiseExternalKeys() {
|
||||||
|
return promiseComposition().then(mapUtilizationResources);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise identifiers for resources used
|
||||||
|
function promiseResourceKeys() {
|
||||||
|
return $q.all([
|
||||||
|
promiseInternalKeys(),
|
||||||
|
promiseExternalKeys()
|
||||||
|
]).then(flatten).then(sort).then(uniquify);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise all utilization
|
||||||
|
function promiseAllUtilization() {
|
||||||
|
// Concatenate internal utilization (from activity modes)
|
||||||
|
// with external utilization (from subactivities)
|
||||||
|
return $q.all([
|
||||||
|
promiseInternalUtilization(),
|
||||||
|
promiseExternalUtilization()
|
||||||
|
]).then(flatten);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the keys for resources associated with this object.
|
||||||
|
* @returns {Promise.<string[]>} a promise for resource identifiers
|
||||||
|
*/
|
||||||
|
resources: promiseResourceKeys,
|
||||||
|
/**
|
||||||
|
* Get the resource utilization associated with this
|
||||||
|
* object. Results are not sorted. This requires looking
|
||||||
|
* at contained objects, which in turn must happen
|
||||||
|
* asynchronously, so this returns a promise.
|
||||||
|
* @returns {Promise.<Array>} a promise for all resource
|
||||||
|
* utilizations
|
||||||
|
*/
|
||||||
|
invoke: promiseAllUtilization
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only applies to timelines and activities
|
||||||
|
UtilizationCapability.appliesTo = function (model) {
|
||||||
|
return model &&
|
||||||
|
((model.type === 'warp.timeline') ||
|
||||||
|
(model.type === 'warp.activity'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return UtilizationCapability;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,41 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller which support the Values view of Activity Modes.
|
||||||
|
* @constructor
|
||||||
|
* @param {Array} resources definitions for extensions of
|
||||||
|
* category `warp.resources`
|
||||||
|
*/
|
||||||
|
function ActivityModeValuesController(resources) {
|
||||||
|
var metadata = {};
|
||||||
|
|
||||||
|
// Store metadata for a specific resource type
|
||||||
|
function storeMetadata(resource) {
|
||||||
|
var key = (resource || {}).key;
|
||||||
|
if (key) {
|
||||||
|
metadata[key] = resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the lookup table to resource metadata
|
||||||
|
resources.forEach(storeMetadata);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Look up metadata associated with the specified
|
||||||
|
* resource type.
|
||||||
|
*/
|
||||||
|
metadata: function (key) {
|
||||||
|
return metadata[key];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActivityModeValuesController;
|
||||||
|
}
|
||||||
|
);
|
128
platform/features/timeline/src/controllers/TimelineController.js
Normal file
128
platform/features/timeline/src/controllers/TimelineController.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[
|
||||||
|
'./swimlane/TimelineSwimlanePopulator',
|
||||||
|
'./graph/TimelineGraphPopulator',
|
||||||
|
'./drag/TimelineDragPopulator'
|
||||||
|
],
|
||||||
|
function (
|
||||||
|
TimelineSwimlanePopulator,
|
||||||
|
TimelineGraphPopulator,
|
||||||
|
TimelineDragPopulator
|
||||||
|
) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for the Timeline view.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineController($scope, $q, objectLoader, MINIMUM_DURATION) {
|
||||||
|
var swimlanePopulator = new TimelineSwimlanePopulator(
|
||||||
|
objectLoader,
|
||||||
|
$scope.configuration || {},
|
||||||
|
$scope.selection
|
||||||
|
),
|
||||||
|
graphPopulator = new TimelineGraphPopulator($q),
|
||||||
|
dragPopulator = new TimelineDragPopulator(objectLoader);
|
||||||
|
|
||||||
|
// Hash together all modification times. A sum is sufficient here,
|
||||||
|
// since modified timestamps should be non-decreasing.
|
||||||
|
function modificationSum() {
|
||||||
|
var sum = 0;
|
||||||
|
swimlanePopulator.get().forEach(function (swimlane) {
|
||||||
|
sum += swimlane.domainObject.getModel().modified || 0;
|
||||||
|
});
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce graph states to a watch-able number. A bitmask is
|
||||||
|
// sufficient here, since only ~30 graphed elements make sense
|
||||||
|
// (due to limits on recognizably unique line colors)
|
||||||
|
function graphMask() {
|
||||||
|
var mask = 0, bit = 1;
|
||||||
|
swimlanePopulator.get().forEach(function (swimlane) {
|
||||||
|
mask += swimlane.graph() ? 0 : bit;
|
||||||
|
bit *= 2;
|
||||||
|
});
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repopulate based on detected modification to in-view objects
|
||||||
|
function repopulateSwimlanes() {
|
||||||
|
swimlanePopulator.populate($scope.domainObject);
|
||||||
|
dragPopulator.populate($scope.domainObject);
|
||||||
|
graphPopulator.populate(swimlanePopulator.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repopulate graphs based on modification to swimlane graph state
|
||||||
|
function repopulateGraphs() {
|
||||||
|
graphPopulator.populate(swimlanePopulator.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pixel width for right pane, using zoom controller
|
||||||
|
function width(zoomController) {
|
||||||
|
var start = swimlanePopulator.start(),
|
||||||
|
end = swimlanePopulator.end();
|
||||||
|
return zoomController.toPixels(zoomController.duration(
|
||||||
|
Math.max(end - start, MINIMUM_DURATION)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh resource graphs
|
||||||
|
function refresh() {
|
||||||
|
if (graphPopulator) {
|
||||||
|
graphPopulator.get().forEach(function (graph) {
|
||||||
|
graph.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate swimlane state on changes
|
||||||
|
$scope.$watch("domainObject", swimlanePopulator.populate);
|
||||||
|
|
||||||
|
// Also recalculate whenever anything in view is modified
|
||||||
|
$scope.$watch(modificationSum, repopulateSwimlanes);
|
||||||
|
|
||||||
|
// Carry over changes in swimlane set to changes in graphs
|
||||||
|
$scope.$watch(graphMask, repopulateGraphs);
|
||||||
|
|
||||||
|
// Convey current selection to drag handle populator
|
||||||
|
$scope.$watch("selection.get()", dragPopulator.select);
|
||||||
|
|
||||||
|
// Provide initial scroll bar state, container for pane positions
|
||||||
|
$scope.scroll = { x: 0, y: 0 };
|
||||||
|
$scope.panes = {};
|
||||||
|
|
||||||
|
// Expose active set of swimlanes
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the width, in pixels, of the timeline area
|
||||||
|
* @returns {number} width, in pixels
|
||||||
|
*/
|
||||||
|
width: width,
|
||||||
|
/**
|
||||||
|
* Get the swimlanes which should currently be displayed.
|
||||||
|
* @returns {TimelineSwimlane[]} the swimlanes
|
||||||
|
*/
|
||||||
|
swimlanes: swimlanePopulator.get,
|
||||||
|
/**
|
||||||
|
* Get the resource graphs which should currently be displayed.
|
||||||
|
* @returns {TimelineGraph[]} the graphs
|
||||||
|
*/
|
||||||
|
graphs: graphPopulator.get,
|
||||||
|
/**
|
||||||
|
* Get drag handles for the current selection.
|
||||||
|
* @returns {TimelineDragHandle[]} the drag handles
|
||||||
|
*/
|
||||||
|
handles: dragPopulator.get,
|
||||||
|
/**
|
||||||
|
* Refresh resource graphs (during drag.)
|
||||||
|
*/
|
||||||
|
refresh: refresh
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineController;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,67 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control for Gantt bars in a timeline view.
|
||||||
|
* Primarily reesponsible for supporting the positioning of Gantt
|
||||||
|
* bars; particularly, this ensures that the left and right edges
|
||||||
|
* never go to far off screen, because in some environments this
|
||||||
|
* will effect rendering performance without visible results.
|
||||||
|
* @constructor
|
||||||
|
* @param {number} MAXIMUM_OFFSCREEN the maximum number of pixels
|
||||||
|
* allowed to go off-screen (to either the left or the right)
|
||||||
|
*/
|
||||||
|
function TimelineGanttController(MAXIMUM_OFFSCREEN) {
|
||||||
|
// Pixel position for the CSS left property
|
||||||
|
function left(timespan, scroll, toPixels) {
|
||||||
|
return Math.max(
|
||||||
|
toPixels(timespan.getStart()),
|
||||||
|
scroll.x - MAXIMUM_OFFSCREEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pixel value for the CSS width property
|
||||||
|
function width(timespan, scroll, toPixels) {
|
||||||
|
var x = left(timespan, scroll, toPixels),
|
||||||
|
right = Math.min(
|
||||||
|
toPixels(timespan.getEnd()),
|
||||||
|
scroll.x + scroll.width + MAXIMUM_OFFSCREEN
|
||||||
|
);
|
||||||
|
return right - x;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the pixel position for the `left` style property
|
||||||
|
* of a Gantt bar for the specified timespan.
|
||||||
|
* @param {Timespan} timespan the timespan to be represented
|
||||||
|
* @param scroll an object containing an `x` and `width`
|
||||||
|
* property, representing the scroll position and
|
||||||
|
* visible width, respectively.
|
||||||
|
* @param {Function} toPixels a function to convert
|
||||||
|
* a timestamp to a pixel position
|
||||||
|
* @returns {number} the pixel position of the left edge
|
||||||
|
*/
|
||||||
|
left: left,
|
||||||
|
/**
|
||||||
|
* Get the pixel value for the `width` style property
|
||||||
|
* of a Gantt bar for the specified timespan.
|
||||||
|
* @param {Timespan} timespan the timespan to be represented
|
||||||
|
* @param scroll an object containing an `x` and `width`
|
||||||
|
* property, representing the scroll position and
|
||||||
|
* visible width, respectively.
|
||||||
|
* @param {Function} toPixels a function to convert
|
||||||
|
* a timestamp to a pixel position
|
||||||
|
* @returns {number} the pixel width of this Gantt bar
|
||||||
|
*/
|
||||||
|
width: width
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineGanttController;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,76 @@
|
|||||||
|
/*global define*/
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for the graph area of a timeline view.
|
||||||
|
* The set of graphs to show is provided by the timeline
|
||||||
|
* controller and communicated into the template via "parameters"
|
||||||
|
* in scope.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineGraphController($scope, resources) {
|
||||||
|
var resourceMap = {},
|
||||||
|
labelCache = {};
|
||||||
|
|
||||||
|
// Add an element to the resource map
|
||||||
|
function addToResourceMap(resource) {
|
||||||
|
var key = resource.key;
|
||||||
|
if (key && !resourceMap[key]) {
|
||||||
|
resourceMap[key] = resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the display bounds for all graphs to match
|
||||||
|
// scroll and/or width.
|
||||||
|
function updateGraphs(parameters) {
|
||||||
|
(parameters.graphs || []).forEach(function (graph) {
|
||||||
|
graph.setBounds(parameters.origin, parameters.duration);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all resources to map for simpler lookup
|
||||||
|
resources.forEach(addToResourceMap);
|
||||||
|
|
||||||
|
// Update graphs as parameters change
|
||||||
|
$scope.$watchCollection("parameters", updateGraphs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get a label object (suitable to pass into the
|
||||||
|
* `timeline-resource-graph-labels` template) for
|
||||||
|
* the specified graph.
|
||||||
|
* @param {TimelineGraph} the graph to label
|
||||||
|
* @returns {object} an object containing labels
|
||||||
|
*/
|
||||||
|
label: function (graph) {
|
||||||
|
var key = graph.key,
|
||||||
|
resource = resourceMap[key] || {},
|
||||||
|
name = resource.name || "",
|
||||||
|
units = resource.units,
|
||||||
|
min = graph.minimum() || 0,
|
||||||
|
max = graph.maximum() || 0,
|
||||||
|
label = labelCache[key] || {};
|
||||||
|
|
||||||
|
// Cache the label (this is passed into a template,
|
||||||
|
// so avoid excessive digest cycles)
|
||||||
|
labelCache[key] = label;
|
||||||
|
|
||||||
|
// Include units in title
|
||||||
|
label.title = name + (units ? (" (" + units + ")") : "");
|
||||||
|
|
||||||
|
// Provide low, middle, high data values
|
||||||
|
label.low = min.toFixed(3);
|
||||||
|
label.middle = ((min + max) / 2).toFixed(3);
|
||||||
|
label.high = max.toFixed(3);
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineGraphController;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,32 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../TimelineFormatter"],
|
||||||
|
function (TimelineFormatter) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var FORMATTER = new TimelineFormatter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides tabular data for the Timeline's tabular view area.
|
||||||
|
*/
|
||||||
|
function TimelineTableController() {
|
||||||
|
|
||||||
|
function getNiceTime(millis) {
|
||||||
|
return FORMATTER.format(millis);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Return human-readable time in the expected format,
|
||||||
|
* currently SET.
|
||||||
|
* @param {number} millis duration, in millisecond
|
||||||
|
* @return {string} human-readable duration
|
||||||
|
*/
|
||||||
|
niceTime: getNiceTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineTableController;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,97 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../TimelineFormatter"],
|
||||||
|
function (TimelineFormatter) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var FORMATTER = new TimelineFormatter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides labels for the tick mark area of a timeline view.
|
||||||
|
* Since the tick mark regin is potentially extremeley large,
|
||||||
|
* only the subset of ticks which will actually be shown in
|
||||||
|
* view are provided.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineTickController() {
|
||||||
|
var labels = [],
|
||||||
|
lastFirst,
|
||||||
|
lastStep,
|
||||||
|
lastCount,
|
||||||
|
lastStartMillis,
|
||||||
|
lastEndMillis;
|
||||||
|
|
||||||
|
// Actually recalculate the labels from scratch
|
||||||
|
function calculateLabels(first, count, step, toMillis) {
|
||||||
|
var result = [],
|
||||||
|
current;
|
||||||
|
|
||||||
|
// Create enough labels to fill the visible area
|
||||||
|
while (result.length < count) {
|
||||||
|
current = first + step * result.length;
|
||||||
|
result.push({
|
||||||
|
// Horizontal pixel position of this label
|
||||||
|
left: current,
|
||||||
|
// Text to display in this label
|
||||||
|
text: FORMATTER.format(toMillis(current))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tick labels for this pixel span (recalculating if needed)
|
||||||
|
function getLabels(start, width, step, toMillis) {
|
||||||
|
// Calculate parameters for labels (first pixel position, last
|
||||||
|
// pixel position.) These are checked to detect changes.
|
||||||
|
var first = Math.floor(start / step) * step,
|
||||||
|
last = Math.ceil((start + width) / step) * step,
|
||||||
|
count = ((last - first) / step) + 1,
|
||||||
|
startMillis = toMillis(first),
|
||||||
|
endMillis = toMillis(last),
|
||||||
|
changed = (lastFirst !== first) ||
|
||||||
|
(lastCount !== count) ||
|
||||||
|
(lastStep !== step) ||
|
||||||
|
(lastStartMillis !== startMillis) ||
|
||||||
|
(lastEndMillis !== endMillis);
|
||||||
|
|
||||||
|
// This will be used in a template, so only recalculate on
|
||||||
|
// change.
|
||||||
|
if (changed) {
|
||||||
|
labels = calculateLabels(first, count, step, toMillis);
|
||||||
|
// Cache to avoid recomputing later
|
||||||
|
lastFirst = first;
|
||||||
|
lastCount = count;
|
||||||
|
lastStep = step;
|
||||||
|
lastStartMillis = startMillis;
|
||||||
|
lastEndMillis = endMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get labels for use in the visible region of a timeline's
|
||||||
|
* tick mark area. This will return the same array instance
|
||||||
|
* (without recalculating its contents) if called with the
|
||||||
|
* same parameters (and same apparent zoom state, as determined
|
||||||
|
* via `toMillis`), so it is safe to use in a template.
|
||||||
|
*
|
||||||
|
* @param {number} start left-most pixel position in view
|
||||||
|
* @param {number} width pixel width in view
|
||||||
|
* @param {number} step size, in pixels, of each major tick
|
||||||
|
* @param {Function} toMillis function to convert from pixel
|
||||||
|
* positions to milliseconds
|
||||||
|
* @returns {Array} an array of tick mark labels, suitable
|
||||||
|
* for use in the `timeline-ticks` template
|
||||||
|
*/
|
||||||
|
labels: getLabels
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineTickController;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,109 @@
|
|||||||
|
/*global define*/
|
||||||
|
define(
|
||||||
|
['../TimelineFormatter'],
|
||||||
|
function (TimelineFormatter) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
|
||||||
|
var FORMATTER = new TimelineFormatter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls the pan-zoom state of a timeline view.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineZoomController($scope, ZOOM_CONFIGURATION) {
|
||||||
|
// Prefer to start with the middle index
|
||||||
|
var zoomLevels = ZOOM_CONFIGURATION.levels || [ 1000 ],
|
||||||
|
zoomIndex = Math.floor(zoomLevels.length / 2),
|
||||||
|
tickWidth = ZOOM_CONFIGURATION.width || 200,
|
||||||
|
duration = 86400000; // Default duration in view
|
||||||
|
|
||||||
|
// Round a duration to a larger value, to ensure space for editing
|
||||||
|
function roundDuration(value) {
|
||||||
|
// Ensure there's always an extra day or so
|
||||||
|
var sz = zoomLevels[zoomLevels.length - 1];
|
||||||
|
value *= 1.25; // Add 25% padding to start
|
||||||
|
return Math.ceil(value / sz) * sz;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get/set zoom level
|
||||||
|
function setZoomLevel(level) {
|
||||||
|
if (!isNaN(level)) {
|
||||||
|
// Modify zoom level, keeping it in range
|
||||||
|
zoomIndex = Math.min(
|
||||||
|
Math.max(level, 0),
|
||||||
|
zoomLevels.length - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist current zoom level
|
||||||
|
function storeZoom() {
|
||||||
|
var isEditMode = $scope.commit &&
|
||||||
|
$scope.domainObject &&
|
||||||
|
$scope.domainObject.hasCapability('editor');
|
||||||
|
if (isEditMode) {
|
||||||
|
$scope.configuration = $scope.configuration || {};
|
||||||
|
$scope.configuration.zoomLevel = zoomIndex;
|
||||||
|
$scope.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.$watch("configuration.zoomLevel", setZoomLevel);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Increase or decrease the current zoom level by a given
|
||||||
|
* number of steps. Positive steps zoom in, negative steps
|
||||||
|
* zoom out.
|
||||||
|
* If called with no arguments, this returns the current
|
||||||
|
* zoom level, expressed as the number of milliseconds
|
||||||
|
* associated with a given tick mark.
|
||||||
|
* @param {number} steps how many steps to zoom in
|
||||||
|
* @returns {number} current zoom level (as the size of a
|
||||||
|
* major tick mark, in pixels)
|
||||||
|
*/
|
||||||
|
zoom: function (amount) {
|
||||||
|
// Update the zoom level if called with an argument
|
||||||
|
if (arguments.length > 0 && !isNaN(amount)) {
|
||||||
|
setZoomLevel(zoomIndex + amount);
|
||||||
|
storeZoom(zoomIndex);
|
||||||
|
}
|
||||||
|
return zoomLevels[zoomIndex];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the width, in pixels, of a specific time duration at
|
||||||
|
* the current zoom level.
|
||||||
|
* @returns {number} the number of pixels
|
||||||
|
*/
|
||||||
|
toPixels: function (millis) {
|
||||||
|
return tickWidth * millis / zoomLevels[zoomIndex];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the time duration, in milliseconds, occupied by the
|
||||||
|
* width (specified in pixels) at the current zoom level.
|
||||||
|
* @returns {number} the number of pixels
|
||||||
|
*/
|
||||||
|
toMillis: function (pixels) {
|
||||||
|
return (pixels / tickWidth) * zoomLevels[zoomIndex];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get or set the current displayed duration. If used as a
|
||||||
|
* setter, this will typically be rounded up to ensure extra
|
||||||
|
* space is available at the right.
|
||||||
|
* @returns {number} duration, in milliseconds
|
||||||
|
*/
|
||||||
|
duration: function (value) {
|
||||||
|
var prior = duration;
|
||||||
|
if (arguments.length > 0) {
|
||||||
|
duration = roundDuration(value);
|
||||||
|
}
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineZoomController;
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,72 @@
|
|||||||
|
/*global define,moment*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for the `datetime` form control.
|
||||||
|
* This is a composite control; it includes multiple
|
||||||
|
* input fields but outputs a single timestamp (in
|
||||||
|
* milliseconds since start of 1970) to the ngModel.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function DateTimeController($scope) {
|
||||||
|
|
||||||
|
// Update the data model
|
||||||
|
function updateModel(datetime) {
|
||||||
|
var days = parseInt(datetime.days, 10) || 0,
|
||||||
|
hour = parseInt(datetime.hours, 10) || 0,
|
||||||
|
min = parseInt(datetime.minutes, 10) || 0,
|
||||||
|
sec = parseInt(datetime.seconds, 10) || 0,
|
||||||
|
epoch = "SET", // Only permit SET, for now
|
||||||
|
timestamp;
|
||||||
|
|
||||||
|
// Build up timestamp
|
||||||
|
timestamp = days * 24;
|
||||||
|
timestamp = (hour + timestamp) * 60;
|
||||||
|
timestamp = (min + timestamp) * 60;
|
||||||
|
timestamp = (sec + timestamp) * 1000;
|
||||||
|
|
||||||
|
// Set in the model
|
||||||
|
$scope.ngModel[$scope.field] = {
|
||||||
|
timestamp: timestamp,
|
||||||
|
epoch: epoch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the displayed state
|
||||||
|
function updateForm(modelState) {
|
||||||
|
var timestamp = (modelState || {}).timestamp || 0,
|
||||||
|
datetime = $scope.datetime;
|
||||||
|
|
||||||
|
timestamp = Math.floor(timestamp / 1000);
|
||||||
|
datetime.seconds = timestamp % 60;
|
||||||
|
timestamp = Math.floor(timestamp / 60);
|
||||||
|
datetime.minutes = timestamp % 60;
|
||||||
|
timestamp = Math.floor(timestamp / 60);
|
||||||
|
datetime.hours = timestamp % 24;
|
||||||
|
timestamp = Math.floor(timestamp / 24);
|
||||||
|
datetime.days = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve state from field, for watch
|
||||||
|
function getModelState() {
|
||||||
|
return $scope.ngModel[$scope.field];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update value whenever any field changes.
|
||||||
|
$scope.$watchCollection("datetime", updateModel);
|
||||||
|
$scope.$watchCollection(getModelState, updateForm);
|
||||||
|
|
||||||
|
// Initialize the scope
|
||||||
|
$scope.datetime = {};
|
||||||
|
updateForm(getModelState());
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTimeController;
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,55 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./TimelineStartHandle', './TimelineEndHandle', './TimelineMoveHandle'],
|
||||||
|
function (TimelineStartHandle, TimelineEndHandle, TimelineMoveHandle) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
|
||||||
|
var DEFAULT_HANDLES = [
|
||||||
|
TimelineStartHandle,
|
||||||
|
TimelineMoveHandle,
|
||||||
|
TimelineEndHandle
|
||||||
|
],
|
||||||
|
TIMELINE_HANDLES = [
|
||||||
|
TimelineStartHandle,
|
||||||
|
TimelineMoveHandle
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a factory for drag handles for timelines/activities
|
||||||
|
* in a timeline view.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineDragHandleFactory(dragHandler, snapHandler) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Create drag handles for this domain object.
|
||||||
|
* @param {DomainObject} domainObject the object to be
|
||||||
|
* manipulated by these gestures
|
||||||
|
* @returns {Array} array of drag handles
|
||||||
|
*/
|
||||||
|
handles: function (domainObject) {
|
||||||
|
var type = domainObject.getCapability('type'),
|
||||||
|
id = domainObject.getId();
|
||||||
|
|
||||||
|
// Instantiate a handle
|
||||||
|
function instantiate(Handle) {
|
||||||
|
return new Handle(
|
||||||
|
id,
|
||||||
|
dragHandler,
|
||||||
|
snapHandler
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantiate smaller set of handles for timelines
|
||||||
|
return (type && type.instanceOf('warp.timeline') ?
|
||||||
|
TIMELINE_HANDLES : DEFAULT_HANDLES)
|
||||||
|
.map(instantiate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineDragHandleFactory;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,237 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles business logic (mutation of objects, retrieval of start/end
|
||||||
|
* times) associated with drag gestures to manipulate start/end times
|
||||||
|
* of activities and timelines in a Timeline view.
|
||||||
|
* @constructor
|
||||||
|
* @param {DomainObject} domainObject the object being viewed
|
||||||
|
* @param {ObjectLoader} objectLoader service to assist in loading
|
||||||
|
* subtrees
|
||||||
|
*/
|
||||||
|
function TimelineDragHandler(domainObject, objectLoader) {
|
||||||
|
var timespans = {},
|
||||||
|
persists = {},
|
||||||
|
mutations = {},
|
||||||
|
compositions = {},
|
||||||
|
dirty = {};
|
||||||
|
|
||||||
|
// "Cast" a domainObject to an id, if necessary
|
||||||
|
function toId(value) {
|
||||||
|
return (typeof value !== 'string' && value.getId) ?
|
||||||
|
value.getId() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the timespan associated with this domain object
|
||||||
|
function populateCapabilityMaps(domainObject) {
|
||||||
|
var id = domainObject.getId(),
|
||||||
|
timespanPromise = domainObject.useCapability('timespan');
|
||||||
|
if (timespanPromise) {
|
||||||
|
timespanPromise.then(function (timespan) {
|
||||||
|
// Cache that timespan
|
||||||
|
timespans[id] = timespan;
|
||||||
|
// And its mutation capability
|
||||||
|
mutations[id] = domainObject.getCapability('mutation');
|
||||||
|
// Also cache the persistence capability for later
|
||||||
|
persists[id] = domainObject.getCapability('persistence');
|
||||||
|
// And the composition, for bulk moves
|
||||||
|
compositions[id] = domainObject.getModel().composition || [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the id->timespan map
|
||||||
|
function populateTimespans(subgraph) {
|
||||||
|
populateCapabilityMaps(subgraph.domainObject);
|
||||||
|
subgraph.composition.forEach(populateTimespans);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist changes for objects by id (when dragging ends)
|
||||||
|
function doPersist(id) {
|
||||||
|
var persistence = persists[id],
|
||||||
|
mutation = mutations[id];
|
||||||
|
if (mutation) {
|
||||||
|
// Mutate just to update the timestamp (since we
|
||||||
|
// explicitly don't do this during the drag to
|
||||||
|
// avoid firing a ton of refreshes.)
|
||||||
|
mutation.mutate(function () {});
|
||||||
|
}
|
||||||
|
if (persistence) {
|
||||||
|
// Persist the changes
|
||||||
|
persistence.persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the object loader to get objects which have timespans
|
||||||
|
objectLoader.load(domainObject, 'timespan').then(populateTimespans);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get a list of identifiers for domain objects which have
|
||||||
|
* timespans that are managed here.
|
||||||
|
* @returns {string[]} ids for all objects which have managed
|
||||||
|
* timespans here
|
||||||
|
*/
|
||||||
|
ids: function () {
|
||||||
|
return Object.keys(timespans).sort();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Persist any changes to timespans that have been made through
|
||||||
|
* this handler.
|
||||||
|
*/
|
||||||
|
persist: function () {
|
||||||
|
// Persist every dirty object...
|
||||||
|
Object.keys(dirty).forEach(doPersist);
|
||||||
|
// Clear out the dirty list
|
||||||
|
dirty = {};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the start time for a specific domain object. The domain
|
||||||
|
* object may be specified by its identifier, or passed as a
|
||||||
|
* domain object instance. If a second, numeric argument is
|
||||||
|
* passed, this functions as a setter.
|
||||||
|
* @returns {number} the start time
|
||||||
|
* @param {string|DomainObject} id the domain object to modify
|
||||||
|
* @param {number} [value] the new value
|
||||||
|
*/
|
||||||
|
start: function (id, value) {
|
||||||
|
// Convert to domain object id, look up timespan
|
||||||
|
var timespan = timespans[toId(id)];
|
||||||
|
// Use as setter if argument is present
|
||||||
|
if ((typeof value === 'number') && timespan) {
|
||||||
|
// Set the start (ensuring that it's non-negative,
|
||||||
|
// and not after the end time.)
|
||||||
|
timespan.setStart(
|
||||||
|
Math.min(Math.max(value, 0), timespan.getEnd())
|
||||||
|
);
|
||||||
|
// Mark as dirty for subsequent persistence
|
||||||
|
dirty[toId(id)] = true;
|
||||||
|
}
|
||||||
|
// Return value from the timespan
|
||||||
|
return timespan && timespan.getStart();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the end time for a specific domain object. The domain
|
||||||
|
* object may be specified by its identifier, or passed as a
|
||||||
|
* domain object instance. If a second, numeric argument is
|
||||||
|
* passed, this functions as a setter.
|
||||||
|
* @returns {number} the end time
|
||||||
|
* @param {string|DomainObject} id the domain object to modify
|
||||||
|
* @param {number} [value] the new value
|
||||||
|
*/
|
||||||
|
end: function (id, value) {
|
||||||
|
// Convert to domain object id, look up timespan
|
||||||
|
var timespan = timespans[toId(id)];
|
||||||
|
// Use as setter if argument is present
|
||||||
|
if ((typeof value === 'number') && timespan) {
|
||||||
|
// Set the end (ensuring it doesn't preceed start)
|
||||||
|
timespan.setEnd(
|
||||||
|
Math.max(value, timespan.getStart())
|
||||||
|
);
|
||||||
|
// Mark as dirty for subsequent persistence
|
||||||
|
dirty[toId(id)] = true;
|
||||||
|
}
|
||||||
|
// Return value from the timespan
|
||||||
|
return timespan && timespan.getEnd();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the duration for a specific domain object. The domain
|
||||||
|
* object may be specified by its identifier, or passed as a
|
||||||
|
* domain object instance. If a second, numeric argument is
|
||||||
|
* passed, this functions as a setter.
|
||||||
|
* @returns {number} the duration
|
||||||
|
* @param {string|DomainObject} id the domain object to modify
|
||||||
|
* @param {number} [value] the new value
|
||||||
|
*/
|
||||||
|
duration: function (id, value) {
|
||||||
|
// Convert to domain object id, look up timespan
|
||||||
|
var timespan = timespans[toId(id)];
|
||||||
|
// Use as setter if argument is present
|
||||||
|
if ((typeof value === 'number') && timespan) {
|
||||||
|
// Set duration (ensure that it's non-negative)
|
||||||
|
timespan.setDuration(
|
||||||
|
Math.max(value, 0)
|
||||||
|
);
|
||||||
|
// Mark as dirty for subsequent persistence
|
||||||
|
dirty[toId(id)] = true;
|
||||||
|
}
|
||||||
|
// Return value from the timespan
|
||||||
|
return timespan && timespan.getDuration();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Move the start and end of this domain object by the
|
||||||
|
* specified delta. Contained objects will move as well.
|
||||||
|
* @param {string|DomainObject} id the domain object to modify
|
||||||
|
* @param {number} delta the amount by which to change
|
||||||
|
*/
|
||||||
|
move: function (id, delta) {
|
||||||
|
// Overview of algorithm used here:
|
||||||
|
// - Build up list of ids to actually move
|
||||||
|
// - Find the minimum start time
|
||||||
|
// - Change delta so it cannot move minimum past 0
|
||||||
|
// - Update start, then end time
|
||||||
|
var ids = {},
|
||||||
|
queue = [toId(id)],
|
||||||
|
minStart;
|
||||||
|
|
||||||
|
// Update start & end, in that order
|
||||||
|
function updateStartEnd(id) {
|
||||||
|
var timespan = timespans[id], start, end;
|
||||||
|
if (timespan) {
|
||||||
|
// Get start/end so we don't get fooled by our
|
||||||
|
// own adjustments
|
||||||
|
start = timespan.getStart();
|
||||||
|
end = timespan.getEnd();
|
||||||
|
// Update start, then end
|
||||||
|
timespan.setStart(start + delta);
|
||||||
|
timespan.setEnd(end + delta);
|
||||||
|
// Mark as dirty for subsequent persistence
|
||||||
|
dirty[toId(id)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build up set of ids
|
||||||
|
while (queue.length > 0) {
|
||||||
|
// Get the next id to consider
|
||||||
|
id = queue.shift();
|
||||||
|
// If we haven't already considered this...
|
||||||
|
if (!ids[id]) {
|
||||||
|
// Add it to the set
|
||||||
|
ids[id] = true;
|
||||||
|
// And queue up its composition
|
||||||
|
queue = queue.concat(compositions[id] || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the minimum start time
|
||||||
|
minStart = Object.keys(ids).map(function (id) {
|
||||||
|
// Get the start time; default to +Inf if not
|
||||||
|
// found, since this will not survive a min
|
||||||
|
// test if any real timespans are present
|
||||||
|
return timespans[id] ?
|
||||||
|
timespans[id].getStart() :
|
||||||
|
Number.POSITIVE_INFINITY;
|
||||||
|
}).reduce(function (a, b) {
|
||||||
|
// Reduce with a minimum test
|
||||||
|
return Math.min(a, b);
|
||||||
|
}, Number.POSITIVE_INFINITY);
|
||||||
|
|
||||||
|
// Ensure delta doesn't exceed bounds
|
||||||
|
delta = Math.max(delta, -minStart);
|
||||||
|
|
||||||
|
// Update start/end times
|
||||||
|
if (delta !== 0) {
|
||||||
|
Object.keys(ids).forEach(updateStartEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineDragHandler;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,76 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./TimelineDragHandler', './TimelineSnapHandler', './TimelineDragHandleFactory'],
|
||||||
|
function (TimelineDragHandler, TimelineSnapHandler, TimelineDragHandleFactory) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides drag handles for the active selection in a timeline view.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineDragPopulator(objectLoader) {
|
||||||
|
var handles = [],
|
||||||
|
factory,
|
||||||
|
selectedObject;
|
||||||
|
|
||||||
|
// Refresh active set of drag handles
|
||||||
|
function refreshHandles() {
|
||||||
|
handles = (factory && selectedObject) ?
|
||||||
|
factory.handles(selectedObject) :
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new factory for handles, based on root object in view
|
||||||
|
function populateForObject(domainObject) {
|
||||||
|
var dragHandler = domainObject && new TimelineDragHandler(
|
||||||
|
domainObject,
|
||||||
|
objectLoader
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reinstantiate the factory
|
||||||
|
factory = dragHandler && new TimelineDragHandleFactory(
|
||||||
|
dragHandler,
|
||||||
|
new TimelineSnapHandler(dragHandler)
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there's a selected object, restore the handles
|
||||||
|
refreshHandles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the current selection
|
||||||
|
function select(swimlane) {
|
||||||
|
// Cache selection to restore handles if other changes occur
|
||||||
|
selectedObject = swimlane && swimlane.domainObject;
|
||||||
|
|
||||||
|
// Provide handles for this selection, if it's defined
|
||||||
|
refreshHandles();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the currently-applicable set of drag handles.
|
||||||
|
* @returns {Array} drag handles
|
||||||
|
*/
|
||||||
|
get: function () {
|
||||||
|
return handles;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Set the root object in view. Drag interactions consider
|
||||||
|
* the full graph for snapping behavior, so this is needed.
|
||||||
|
* @param {DomainObject} domainObject the timeline object
|
||||||
|
* being viewed
|
||||||
|
*/
|
||||||
|
populate: populateForObject,
|
||||||
|
/**
|
||||||
|
* Update selection state. Passing undefined means there
|
||||||
|
* is no selection.
|
||||||
|
* @param {TimelineSwimlane} swimlane the selected swimlane
|
||||||
|
*/
|
||||||
|
select: select
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineDragPopulator;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,77 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../TimelineConstants'],
|
||||||
|
function (Constants) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle for changing the end time of a timeline or
|
||||||
|
* activity in the Timeline view.
|
||||||
|
* @constructor
|
||||||
|
* @param {string} id identifier of the domain object
|
||||||
|
* @param {TimelineDragHandler} dragHandler the handler which
|
||||||
|
* will update object state
|
||||||
|
* @param {TimelineSnapHandler} snapHandler the handler which
|
||||||
|
* provides candidate snap-to locations.
|
||||||
|
*/
|
||||||
|
function TimelineEndHandle(id, dragHandler, snapHandler) {
|
||||||
|
var initialEnd;
|
||||||
|
|
||||||
|
// Get the snap-to location for a timestamp
|
||||||
|
function snap(timestamp, zoom) {
|
||||||
|
return snapHandler.snap(
|
||||||
|
timestamp,
|
||||||
|
zoom.toMillis(Constants.SNAP_WIDTH),
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Start dragging this handle.
|
||||||
|
*/
|
||||||
|
begin: function () {
|
||||||
|
// Cache the initial state
|
||||||
|
initialEnd = dragHandler.end(id);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Drag this handle.
|
||||||
|
* @param {number} delta pixel delta from start
|
||||||
|
* @param {TimelineZoomController} zoom provider of zoom state
|
||||||
|
*/
|
||||||
|
drag: function (delta, zoom) {
|
||||||
|
if (initialEnd !== undefined) {
|
||||||
|
// Update the state
|
||||||
|
dragHandler.end(
|
||||||
|
id,
|
||||||
|
snap(initialEnd + zoom.toMillis(delta), zoom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Finish dragging this handle.
|
||||||
|
*/
|
||||||
|
finish: function () {
|
||||||
|
// Clear initial state
|
||||||
|
initialEnd = undefined;
|
||||||
|
// Persist changes
|
||||||
|
dragHandler.persist();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get a style object (suitable for passing into `ng-style`)
|
||||||
|
* for this handle.
|
||||||
|
* @param {TimelineZoomController} zoom provider of zoom state
|
||||||
|
*/
|
||||||
|
style: function (zoom) {
|
||||||
|
return {
|
||||||
|
left: zoom.toPixels(dragHandler.end(id)) - Constants.HANDLE_WIDTH + 'px',
|
||||||
|
width: Constants.HANDLE_WIDTH + 'px'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineEndHandle;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,115 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../TimelineConstants'],
|
||||||
|
function (Constants) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle for moving (by drag) a timeline or
|
||||||
|
* activity in the Timeline view.
|
||||||
|
* @constructor
|
||||||
|
* @param {string} id identifier of the domain object
|
||||||
|
* @param {TimelineDragHandler} dragHandler the handler which
|
||||||
|
* will update object state
|
||||||
|
* @param {TimelineSnapHandler} snapHandler the handler which
|
||||||
|
* provides candidate snap-to locations.
|
||||||
|
*/
|
||||||
|
function TimelineMoveHandle(id, dragHandler, snapHandler) {
|
||||||
|
var initialStart,
|
||||||
|
initialEnd;
|
||||||
|
|
||||||
|
// Get the snap-to location for a timestamp
|
||||||
|
function snap(timestamp, zoom) {
|
||||||
|
return snapHandler.snap(
|
||||||
|
timestamp,
|
||||||
|
zoom.toMillis(Constants.SNAP_WIDTH),
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a pixel delta to a millisecond delta that will align
|
||||||
|
// with some useful snap location
|
||||||
|
function snapDelta(delta, zoom) {
|
||||||
|
var timeDelta = zoom.toMillis(delta),
|
||||||
|
desiredStart = initialStart + timeDelta,
|
||||||
|
desiredEnd = initialEnd + timeDelta,
|
||||||
|
snappedStart = snap(desiredStart, zoom),
|
||||||
|
snappedEnd = snap(desiredEnd, zoom),
|
||||||
|
diffStart = Math.abs(snappedStart - desiredStart),
|
||||||
|
diffEnd = Math.abs(snappedEnd - desiredEnd),
|
||||||
|
chooseEnd = false;
|
||||||
|
|
||||||
|
// First, check for case where both changed...
|
||||||
|
if ((diffStart > 0) && (diffEnd > 0)) {
|
||||||
|
// ...and choose the smallest change that snaps.
|
||||||
|
chooseEnd = diffEnd < diffStart;
|
||||||
|
} else {
|
||||||
|
// ...otherwise, snap toward the end if it changed.
|
||||||
|
chooseEnd = diffEnd > 0;
|
||||||
|
}
|
||||||
|
// Start is chosen if diffEnd didn't snap, or nothing snapped
|
||||||
|
|
||||||
|
// Our delta is relative to our initial state, but
|
||||||
|
// dragHandler.move is relative to current state, so whichever
|
||||||
|
// end we're snapping to, we need to compute a delta
|
||||||
|
// relative to the current state to get the desired result.
|
||||||
|
return chooseEnd ?
|
||||||
|
(snappedEnd - dragHandler.end(id)) :
|
||||||
|
(snappedStart - dragHandler.start(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Start dragging this handle.
|
||||||
|
*/
|
||||||
|
begin: function () {
|
||||||
|
// Cache the initial state
|
||||||
|
initialStart = dragHandler.start(id);
|
||||||
|
initialEnd = dragHandler.end(id);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Drag this handle.
|
||||||
|
* @param {number} delta pixel delta from start
|
||||||
|
* @param {TimelineZoomController} zoom provider of zoom state
|
||||||
|
*/
|
||||||
|
drag: function (delta, zoom) {
|
||||||
|
if (initialStart !== undefined && initialEnd !== undefined) {
|
||||||
|
if (delta !== 0) {
|
||||||
|
dragHandler.move(id, snapDelta(delta, zoom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Finish dragging this handle.
|
||||||
|
*/
|
||||||
|
finish: function () {
|
||||||
|
// Clear initial state
|
||||||
|
initialStart = undefined;
|
||||||
|
initialEnd = undefined;
|
||||||
|
// Persist changes
|
||||||
|
dragHandler.persist();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get a style object (suitable for passing into `ng-style`)
|
||||||
|
* for this handle.
|
||||||
|
* @param {TimelineZoomController} zoom provider of zoom state
|
||||||
|
*/
|
||||||
|
|
||||||
|
style: function (zoom) {
|
||||||
|
return {
|
||||||
|
left: zoom.toPixels(dragHandler.start(id)) +
|
||||||
|
Constants.HANDLE_WIDTH +
|
||||||
|
'px',
|
||||||
|
width: zoom.toPixels(dragHandler.duration(id)) -
|
||||||
|
Constants.HANDLE_WIDTH * 2
|
||||||
|
+ 'px'
|
||||||
|
//cursor: initialStart === undefined ? 'grab' : 'grabbing'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineMoveHandle;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,85 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snaps timestamps to match other timestamps within a
|
||||||
|
* certain tolerance, to support the snap-to-start-and-end
|
||||||
|
* behavior of drag interactions in a timeline view.
|
||||||
|
* @constructor
|
||||||
|
* @param {TimelineDragHandler} dragHandler the handler
|
||||||
|
* for drag interactions, which maintains start/end
|
||||||
|
* information for timelines in this view.
|
||||||
|
*/
|
||||||
|
function TimelineSnapHandler(dragHandler) {
|
||||||
|
// Snap to other end points
|
||||||
|
function snap(timestamp, tolerance, exclude) {
|
||||||
|
var result = timestamp,
|
||||||
|
closest = tolerance,
|
||||||
|
ids,
|
||||||
|
candidates;
|
||||||
|
|
||||||
|
// Filter an id for inclustion
|
||||||
|
function include(id) { return id !== exclude; }
|
||||||
|
|
||||||
|
// Evaluate a candidate timestamp as a snap-to location
|
||||||
|
function evaluate(candidate) {
|
||||||
|
var difference = Math.abs(candidate - timestamp);
|
||||||
|
// Is this closer than anything else we've found?
|
||||||
|
if (difference < closest) {
|
||||||
|
// ...then this is our new result
|
||||||
|
result = candidate;
|
||||||
|
// Track how close it was, for subsequent comparison.
|
||||||
|
closest = difference;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up start time; for mapping below
|
||||||
|
function getStart(id) {
|
||||||
|
return dragHandler.start(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up end time; for mapping below
|
||||||
|
function getEnd(id) {
|
||||||
|
return dragHandler.end(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of candidate ids
|
||||||
|
ids = dragHandler.ids().filter(include);
|
||||||
|
|
||||||
|
// Get candidate timestamps
|
||||||
|
candidates = ids.map(getStart).concat(ids.map(getEnd));
|
||||||
|
|
||||||
|
// ...and find the best one
|
||||||
|
candidates.forEach(evaluate);
|
||||||
|
|
||||||
|
// Closest candidate (or original timestamp) is our result
|
||||||
|
// now, so return it.
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get a timestamp location that is near this
|
||||||
|
* timestamp (or simply return the provided
|
||||||
|
* timestamp if none are near enough, according
|
||||||
|
* to the specified tolerance.)
|
||||||
|
* Start/end times associated with the domain object
|
||||||
|
* with the specified identifier will be excluded
|
||||||
|
* from consideration (to avoid an undesired snap-to-self
|
||||||
|
* behavior.)
|
||||||
|
* @param {number} timestamp the timestamp to snap
|
||||||
|
* @param {number} tolerance the difference within which
|
||||||
|
* to snap
|
||||||
|
* @param {string} id the identifier to exclude
|
||||||
|
*/
|
||||||
|
snap: snap
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineSnapHandler;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,77 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../TimelineConstants'],
|
||||||
|
function (Constants) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle for changing the start time of a timeline or
|
||||||
|
* activity in the Timeline view.
|
||||||
|
* @constructor
|
||||||
|
* @param {string} id identifier of the domain object
|
||||||
|
* @param {TimelineDragHandler} dragHandler the handler which
|
||||||
|
* will update object state
|
||||||
|
* @param {TimelineSnapHandler} snapHandler the handler which
|
||||||
|
* provides candidate snap-to locations.
|
||||||
|
*/
|
||||||
|
function TimelineStartHandle(id, dragHandler, snapHandler) {
|
||||||
|
var initialStart;
|
||||||
|
|
||||||
|
// Get the snap-to location for a timestamp
|
||||||
|
function snap(timestamp, zoom) {
|
||||||
|
return snapHandler.snap(
|
||||||
|
timestamp,
|
||||||
|
zoom.toMillis(Constants.SNAP_WIDTH),
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Start dragging this handle.
|
||||||
|
*/
|
||||||
|
begin: function () {
|
||||||
|
// Cache the initial state
|
||||||
|
initialStart = dragHandler.start(id);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Drag this handle.
|
||||||
|
* @param {number} delta pixel delta from start
|
||||||
|
* @param {TimelineZoomController} zoom provider of zoom state
|
||||||
|
*/
|
||||||
|
drag: function (delta, zoom) {
|
||||||
|
if (initialStart !== undefined) {
|
||||||
|
// Update the state
|
||||||
|
dragHandler.start(
|
||||||
|
id,
|
||||||
|
snap(initialStart + zoom.toMillis(delta), zoom)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Finish dragging this handle.
|
||||||
|
*/
|
||||||
|
finish: function () {
|
||||||
|
// Clear initial state
|
||||||
|
initialStart = undefined;
|
||||||
|
// Persist changes
|
||||||
|
dragHandler.persist();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get a style object (suitable for passing into `ng-style`)
|
||||||
|
* for this handle.
|
||||||
|
* @param {TimelineZoomController} zoom provider of zoom state
|
||||||
|
*/
|
||||||
|
style: function (zoom) {
|
||||||
|
return {
|
||||||
|
left: zoom.toPixels(dragHandler.start(id)) + 'px',
|
||||||
|
width: Constants.HANDLE_WIDTH + 'px'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineStartHandle;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,172 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides data to populate a graph in a timeline view.
|
||||||
|
* @constructor
|
||||||
|
* @param {string} key the resource's identifying key
|
||||||
|
* @param {Object.<string,DomainObject>} domainObjects and object
|
||||||
|
* containing key-value pairs where keys are colors, and
|
||||||
|
* values are DomainObject instances to be drawn in that
|
||||||
|
* color
|
||||||
|
* @param {TimelineGraphRenderer} renderer a renderer which
|
||||||
|
* can be used to prepare Float32Array instances
|
||||||
|
*/
|
||||||
|
function TimelineGraph(key, domainObjects, renderer) {
|
||||||
|
var drawingObject = { origin: [0, 0], dimensions: [0, 0], modified: 0},
|
||||||
|
// lines for the drawing object, by swimlane index
|
||||||
|
lines = [],
|
||||||
|
// min/max seen for a given swimlane, by swimlane index
|
||||||
|
extrema = [],
|
||||||
|
// current minimum
|
||||||
|
min = 0,
|
||||||
|
// current maximum
|
||||||
|
max = 0,
|
||||||
|
// current displayed time span
|
||||||
|
duration = 1000,
|
||||||
|
// line colors to display
|
||||||
|
colors = Object.keys(domainObjects);
|
||||||
|
|
||||||
|
// Get minimum value, ensure there's some room
|
||||||
|
function minimum() {
|
||||||
|
return (min >= max) ? (max - 1) : min;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get maximum value, ensure there's some room
|
||||||
|
function maximum() {
|
||||||
|
return (min >= max) ? (min + 1) : max;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update minimum and maximum values
|
||||||
|
function updateMinMax() {
|
||||||
|
// Find the minimum among plot lines
|
||||||
|
min = extrema.map(function (ex) {
|
||||||
|
return ex.min;
|
||||||
|
}).reduce(function (a, b) {
|
||||||
|
return Math.min(a, b);
|
||||||
|
}, Number.POSITIVE_INFINITY);
|
||||||
|
|
||||||
|
// Do the same for the maximum
|
||||||
|
max = extrema.map(function (ex) {
|
||||||
|
return ex.max;
|
||||||
|
}).reduce(function (a, b) {
|
||||||
|
return Math.max(a, b);
|
||||||
|
}, Number.NEGATIVE_INFINITY);
|
||||||
|
|
||||||
|
// Ensure the infinities don't survive
|
||||||
|
min = min === Number.POSITIVE_INFINITY ? max : min;
|
||||||
|
min = min === Number.NEGATIVE_INFINITY ? 0 : min;
|
||||||
|
max = max === Number.NEGATIVE_INFINITY ? min : max;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change contents of the drawing object (to trigger redraw)
|
||||||
|
function updateDrawingObject() {
|
||||||
|
// Update drawing object to include non-empty lines
|
||||||
|
drawingObject.lines = lines.filter(function (line) {
|
||||||
|
return line.points > 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update drawing bounds to fit data
|
||||||
|
drawingObject.origin[1] = minimum();
|
||||||
|
drawingObject.dimensions[1] = maximum() - minimum();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a specific line, by index
|
||||||
|
function updateLine(graph, index) {
|
||||||
|
var buffer = renderer.render(graph),
|
||||||
|
line = lines[index],
|
||||||
|
ex = extrema[index],
|
||||||
|
i;
|
||||||
|
|
||||||
|
// Track minimum/maximum; note we skip x values
|
||||||
|
for (i = 1; i < buffer.length; i += 2) {
|
||||||
|
ex.min = Math.min(buffer[i], ex.min);
|
||||||
|
ex.max = Math.max(buffer[i], ex.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update line in drawing object
|
||||||
|
line.buffer = buffer;
|
||||||
|
line.points = graph.getPointCount();
|
||||||
|
line.color = renderer.decode(colors[index]);
|
||||||
|
|
||||||
|
// Update the graph's total min/max
|
||||||
|
if (line.points > 0) {
|
||||||
|
updateMinMax();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the drawing object (used to draw the graph)
|
||||||
|
updateDrawingObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request initialization for a line's contents
|
||||||
|
function populateLine(color, index) {
|
||||||
|
var domainObject = domainObjects[color],
|
||||||
|
graphPromise = domainObject.useCapability('graph');
|
||||||
|
|
||||||
|
if (graphPromise) {
|
||||||
|
graphPromise.then(function (g) {
|
||||||
|
if (g[key]) {
|
||||||
|
updateLine(g[key], index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty lines
|
||||||
|
lines = colors.map(function () {
|
||||||
|
// Sentinel value to exclude these lines
|
||||||
|
return { points: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Specify initial min/max state per-line
|
||||||
|
extrema = colors.map(function () {
|
||||||
|
return {
|
||||||
|
min: Number.POSITIVE_INFINITY,
|
||||||
|
max: Number.NEGATIVE_INFINITY
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start creating lines for all swimlanes
|
||||||
|
colors.forEach(populateLine);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the minimum resource value that appears in this graph.
|
||||||
|
* @returns {number} the minimum value
|
||||||
|
*/
|
||||||
|
minimum: minimum,
|
||||||
|
/**
|
||||||
|
* Get the maximum resource value that appears in this graph.
|
||||||
|
* @returns {number} the maximum value
|
||||||
|
*/
|
||||||
|
maximum: maximum,
|
||||||
|
/**
|
||||||
|
* Set the displayed origin and duration, in milliseconds.
|
||||||
|
* @param {number} [value] value to set, if setting
|
||||||
|
*/
|
||||||
|
setBounds: function (offset, duration) {
|
||||||
|
// We don't update in-place, because we need the change
|
||||||
|
// to trigger a watch in mct-chart.
|
||||||
|
drawingObject.origin = [ offset, drawingObject.origin[1] ];
|
||||||
|
drawingObject.dimensions = [ duration, drawingObject.dimensions[1] ];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Redraw lines in this graph.
|
||||||
|
*/
|
||||||
|
refresh: function () {
|
||||||
|
colors.forEach(populateLine);
|
||||||
|
},
|
||||||
|
// Expose key, drawing object directly for use in templates
|
||||||
|
key: key,
|
||||||
|
drawingObject: drawingObject
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineGraph;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,136 @@
|
|||||||
|
/*global define*/
|
||||||
|
define(
|
||||||
|
['./TimelineGraph', './TimelineGraphRenderer'],
|
||||||
|
function (TimelineGraph, TimelineGraphRenderer) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsible for determining which resource graphs
|
||||||
|
* to display (based on capabilities exposed by included
|
||||||
|
* domain objects) and allocating data to those different
|
||||||
|
* graphs.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineGraphPopulator($q) {
|
||||||
|
var graphs = [],
|
||||||
|
cachedAssignments = {},
|
||||||
|
renderer = new TimelineGraphRenderer();
|
||||||
|
|
||||||
|
// Compare two domain objects
|
||||||
|
function idsMatch(objA, objB) {
|
||||||
|
return (objA && objA.getId && objA.getId()) ===
|
||||||
|
(objB && objB.getId && objB.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two object sets for equality, to detect
|
||||||
|
// when graph updates are truly needed.
|
||||||
|
function deepEquals(objA, objB) {
|
||||||
|
var keysA, keysB;
|
||||||
|
|
||||||
|
// Check if all keys in both objects match
|
||||||
|
function keysMatch(keys) {
|
||||||
|
return keys.map(function (k) {
|
||||||
|
return deepEquals(objA[k], objB[k]);
|
||||||
|
}).reduce(function (a, b) {
|
||||||
|
return a && b;
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, check if they're matching domain objects
|
||||||
|
if (typeof (objA && objA.getId) === 'function') {
|
||||||
|
return idsMatch(objA, objB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, assume key-value pairs
|
||||||
|
keysA = Object.keys(objA || {}).sort();
|
||||||
|
keysB = Object.keys(objB || {}).sort();
|
||||||
|
|
||||||
|
return (keysA.length === keysB.length) && keysMatch(keysA);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the graphs for these swimlanes
|
||||||
|
function populate(swimlanes) {
|
||||||
|
// Somewhere to store resource assignments
|
||||||
|
// (as key -> swimlane[])
|
||||||
|
var assignments = {};
|
||||||
|
|
||||||
|
// Look up resources for a domain object
|
||||||
|
function lookupResources(swimlane) {
|
||||||
|
var graphs = swimlane.domainObject.useCapability('graph');
|
||||||
|
function getKeys(obj) {
|
||||||
|
return Object.keys(obj);
|
||||||
|
}
|
||||||
|
return $q.when(graphs ? (graphs.then(getKeys)) : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all graph assignments appropriate for this swimlane
|
||||||
|
function buildAssignments(swimlane) {
|
||||||
|
// Assign this swimlane to graphs for its resource keys
|
||||||
|
return lookupResources(swimlane).then(function (resources) {
|
||||||
|
resources.forEach(function (key) {
|
||||||
|
assignments[key] = assignments[key] || {};
|
||||||
|
assignments[key][swimlane.color()] =
|
||||||
|
swimlane.domainObject;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a graph for this resource (after assigning)
|
||||||
|
function makeGraph(key) {
|
||||||
|
return new TimelineGraph(
|
||||||
|
key,
|
||||||
|
assignments[key],
|
||||||
|
renderer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to filter down to swimlanes which need graphs
|
||||||
|
function needsGraph(swimlane) {
|
||||||
|
// Only show swimlanes with graphs & resources to graph
|
||||||
|
return swimlane.graph() &&
|
||||||
|
swimlane.domainObject.hasCapability('graph');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create graphs according to assignments that have been built
|
||||||
|
function createGraphs() {
|
||||||
|
// Only refresh graphs if our assignments actually changed
|
||||||
|
if (!deepEquals(cachedAssignments, assignments)) {
|
||||||
|
// Make new graphs
|
||||||
|
graphs = Object.keys(assignments).sort().map(makeGraph);
|
||||||
|
// Save resource->color->object assignments
|
||||||
|
cachedAssignments = assignments;
|
||||||
|
} else {
|
||||||
|
// Just refresh the existing graphs
|
||||||
|
graphs.forEach(function (graph) {
|
||||||
|
graph.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build up list of assignments, then create graphs
|
||||||
|
$q.all(swimlanes.filter(needsGraph).map(buildAssignments))
|
||||||
|
.then(createGraphs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Populate (or re-populate) the list of available resource
|
||||||
|
* graphs, based on the provided list of swimlanes (and their
|
||||||
|
* current state.)
|
||||||
|
* @param {TimelineSwimlane[]} swimlanes the swimlanes to use
|
||||||
|
*/
|
||||||
|
populate: populate,
|
||||||
|
/**
|
||||||
|
* Get the current list of displayable resource graphs.
|
||||||
|
* @returns {TimelineGraph[]} the resource graphs
|
||||||
|
*/
|
||||||
|
get: function () {
|
||||||
|
return graphs;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineGraphPopulator;
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,62 @@
|
|||||||
|
/*global define,Float32Array*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsible for preparing data for display by
|
||||||
|
* `mct-chart` in a timeline's resource graph.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineGraphRenderer() {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Render a resource utilization to a Float32Array,
|
||||||
|
* to be passed to WebGL for display.
|
||||||
|
* @param {ResourceGraph} graph the resource utilization
|
||||||
|
* @returns {Float32Array} the rendered buffer
|
||||||
|
*/
|
||||||
|
render: function (graph) {
|
||||||
|
var count = graph.getPointCount(),
|
||||||
|
buffer = new Float32Array(count * 2),
|
||||||
|
i;
|
||||||
|
|
||||||
|
// Populate the buffer
|
||||||
|
for (i = 0; i < count; i += 1) {
|
||||||
|
buffer[i * 2] = graph.getDomainValue(i);
|
||||||
|
buffer[i * 2 + 1] = graph.getRangeValue(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Convert an HTML color (in #-prefixed 6-digit hexadecimal)
|
||||||
|
* to an array of floating point values in a range of 0.0-1.0.
|
||||||
|
* An alpha element is included to facilitate display in an
|
||||||
|
* `mct-chart` (which uses WebGL.)
|
||||||
|
* @param {string} the color
|
||||||
|
* @returns {number[]} the same color, in floating-point format
|
||||||
|
*/
|
||||||
|
decode: function (color) {
|
||||||
|
// Check for bad input, default to black if needed
|
||||||
|
color = /^#[A-Fa-f0-9]{6}$/.test(color) ? color : "#000000";
|
||||||
|
|
||||||
|
// Pull out R, G, B hex values
|
||||||
|
return [
|
||||||
|
color.substring(1, 3),
|
||||||
|
color.substring(3, 5),
|
||||||
|
color.substring(5, 7)
|
||||||
|
].map(function (c) {
|
||||||
|
// Hex -> number
|
||||||
|
return parseInt(c, 16) / 255;
|
||||||
|
}).concat([1]); // Add the alpha channel
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineGraphRenderer;
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,101 @@
|
|||||||
|
/*global define*/
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var COLOR_OPTIONS = [
|
||||||
|
"#20b2aa",
|
||||||
|
"#9acd32",
|
||||||
|
"#ff8c00",
|
||||||
|
"#d2b48c",
|
||||||
|
"#40e0d0",
|
||||||
|
"#4169ff",
|
||||||
|
"#ffd700",
|
||||||
|
"#6a5acd",
|
||||||
|
"#ee82ee",
|
||||||
|
"#cc9966",
|
||||||
|
"#99cccc",
|
||||||
|
"#66cc33",
|
||||||
|
"#ffcc00",
|
||||||
|
"#ff6633",
|
||||||
|
"#cc66ff",
|
||||||
|
"#ff0066",
|
||||||
|
"#ffff00",
|
||||||
|
"#800080",
|
||||||
|
"#00868b",
|
||||||
|
"#008a00",
|
||||||
|
"#ff0000",
|
||||||
|
"#0000ff",
|
||||||
|
"#f5deb3",
|
||||||
|
"#bc8f8f",
|
||||||
|
"#4682b4",
|
||||||
|
"#ffafaf",
|
||||||
|
"#43cd80",
|
||||||
|
"#cdc1c5",
|
||||||
|
"#a0522d",
|
||||||
|
"#6495ed"
|
||||||
|
],
|
||||||
|
// Fall back to black, as "no more colors available"
|
||||||
|
FALLBACK_COLOR = "#000000";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsible for choosing unique colors for the resource
|
||||||
|
* graph listing of a timeline view. Supports TimelineController.
|
||||||
|
* @constructor
|
||||||
|
* @param colors an object to store color configuration into;
|
||||||
|
* typically, this should be a property from the view's
|
||||||
|
* configuration, but TimelineSwimlane manages this.
|
||||||
|
*/
|
||||||
|
function TimelineColorAssigner(colors) {
|
||||||
|
// Find an unused color
|
||||||
|
function freeColor() {
|
||||||
|
// Set of used colors
|
||||||
|
var set = {}, found;
|
||||||
|
|
||||||
|
// Build up a set of used colors
|
||||||
|
Object.keys(colors).forEach(function (id) {
|
||||||
|
set[colors[id]] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find an unused color
|
||||||
|
COLOR_OPTIONS.forEach(function (c) {
|
||||||
|
found = (!set[c] && !found) ? c : found;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide the color
|
||||||
|
return found || FALLBACK_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get the current color assignment.
|
||||||
|
* @param {string} id the id to which the color is assigned
|
||||||
|
*/
|
||||||
|
get: function (id) {
|
||||||
|
return colors[id];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Assign a new color to this id. If no color is specified,
|
||||||
|
* an unused color will be chosen.
|
||||||
|
* @param {string} id the id to which the color is assigned
|
||||||
|
* @param {string} [color] the new color to assign
|
||||||
|
*/
|
||||||
|
assign: function (id, color) {
|
||||||
|
colors[id] = typeof color === 'string' ? color : freeColor();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Release the color assignment for this id. That id will
|
||||||
|
* no longer have a color associated with it, and its color
|
||||||
|
* will be free to use in subsequent calls.
|
||||||
|
* @param {string} id the id whose color should be released
|
||||||
|
*/
|
||||||
|
release: function (id) {
|
||||||
|
delete colors[id];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineColorAssigner;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,58 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection proxy for the Timeline view. Implements
|
||||||
|
* behavior associated with the Add button in the
|
||||||
|
* timeline's toolbar.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineProxy(domainObject, selection) {
|
||||||
|
var actionMap = {};
|
||||||
|
|
||||||
|
// Populate available Create actions for this domain object
|
||||||
|
function populateActionMap(domainObject) {
|
||||||
|
var actionCapability = domainObject.getCapability('action'),
|
||||||
|
actions = actionCapability ?
|
||||||
|
actionCapability.getActions('create') : [];
|
||||||
|
actions.forEach(function (action) {
|
||||||
|
actionMap[action.getMetadata().type] = action;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate available actions based on current selection
|
||||||
|
// (defaulting to object-in-view if there is none.)
|
||||||
|
function populateForSelection() {
|
||||||
|
var swimlane = selection && selection.get(),
|
||||||
|
selectedObject = swimlane && swimlane.domainObject;
|
||||||
|
populateActionMap(selectedObject || domainObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
populateActionMap(domainObject);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Add a domain object of the specified type.
|
||||||
|
* @param {string} type the type of domain object to add
|
||||||
|
*/
|
||||||
|
add: function (type) {
|
||||||
|
// Update list of create actions; this needs to reflect
|
||||||
|
// the current selection so that Save in defaults
|
||||||
|
// appropriately.
|
||||||
|
populateForSelection();
|
||||||
|
|
||||||
|
// Create an object of that type
|
||||||
|
if (actionMap[type]) {
|
||||||
|
return actionMap[type].perform();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineProxy;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,156 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a swimlane in a timeline view. This will be
|
||||||
|
* used directly from timeline view.
|
||||||
|
*
|
||||||
|
* Only general properties of swimlanes are included here.
|
||||||
|
* Since swimlanes are also directly selected and exposed to the
|
||||||
|
* toolbar, the TimelineSwimlaneDecorator should also be used
|
||||||
|
* to add additional properties to specific swimlanes.
|
||||||
|
*
|
||||||
|
* @constructor
|
||||||
|
* @param {DomainObject} domainObject the represented object
|
||||||
|
* @param {TimelineColorAssigner} assigner color assignment handler
|
||||||
|
* @param configuration the view's configuration object
|
||||||
|
* @param {TimelineSwimlane} parent the parent swim lane (if any)
|
||||||
|
*/
|
||||||
|
function TimelineSwimlane(domainObject, assigner, configuration, parent, index) {
|
||||||
|
var id = domainObject.getId(),
|
||||||
|
highlight = false, // Drop highlight (middle)
|
||||||
|
highlightBottom = false, // Drop highlight (lower)
|
||||||
|
idPath = (parent ? parent.idPath : []).concat([domainObject.getId()]),
|
||||||
|
depth = parent ? (parent.depth + 1) : 0,
|
||||||
|
timespan,
|
||||||
|
path = (!parent || !parent.parent) ? "" : parent.path +
|
||||||
|
//(parent.path.length > 0 ? " / " : "") +
|
||||||
|
parent.domainObject.getModel().name +
|
||||||
|
" > ";
|
||||||
|
|
||||||
|
// Look up timespan for this object
|
||||||
|
domainObject.useCapability('timespan').then(function (t) {
|
||||||
|
timespan = t;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Check if this swimlane is currently visible. (That is,
|
||||||
|
* check to see if its parents are expanded.)
|
||||||
|
* @returns {boolean} true if it is visible
|
||||||
|
*/
|
||||||
|
visible: function () {
|
||||||
|
return !parent || (parent.expanded && parent.visible());
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Show the Edit Properties dialog.
|
||||||
|
*/
|
||||||
|
properties: function () {
|
||||||
|
return domainObject.getCapability("action").perform("properties");
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Toggle inclusion of this swimlane's represented object in
|
||||||
|
* the resource graph area.
|
||||||
|
*/
|
||||||
|
toggleGraph: function () {
|
||||||
|
configuration.graph = configuration.graph || {};
|
||||||
|
configuration.graph[id] = !configuration.graph[id];
|
||||||
|
// Assign or release legend color
|
||||||
|
assigner[configuration.graph[id] ? 'assign' : 'release'](id);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get (or set, if an argument is provided) the flag which
|
||||||
|
* determines if the object in this swimlane is included in
|
||||||
|
* the set of active resource graphs.
|
||||||
|
* @param {boolean} [value] the state to set (if setting)
|
||||||
|
* @returns {boolean} true if included; otherwise false
|
||||||
|
*/
|
||||||
|
graph: function (value) {
|
||||||
|
// Set if an argument was provided
|
||||||
|
if (arguments.length > 0) {
|
||||||
|
configuration.graph = configuration.graph || {};
|
||||||
|
configuration.graph[id] = !!value;
|
||||||
|
// Assign or release the legend color
|
||||||
|
assigner[value ? 'assign' : 'release'](id);
|
||||||
|
}
|
||||||
|
// Provide the current state
|
||||||
|
return (configuration.graph || {})[id];
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get (or set, if an argument is provided) the color
|
||||||
|
* associated with this swimlane when its contents are
|
||||||
|
* included in the set of active resource graphs.
|
||||||
|
* @param {string} [value] the color to set (if setting)
|
||||||
|
* @returns {string} the color for resource graphing
|
||||||
|
*/
|
||||||
|
color: function (value) {
|
||||||
|
// Set if an argument was provided
|
||||||
|
if (arguments.length > 0) {
|
||||||
|
// Defer to the color assigner
|
||||||
|
assigner.assign(id, value);
|
||||||
|
}
|
||||||
|
// Provide the current value
|
||||||
|
return assigner.get(id);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get (or set, if an argument is provided) the drag
|
||||||
|
* highlight state for this swimlane. True means the body
|
||||||
|
* of the swimlane should be highlighted for drop into.
|
||||||
|
*/
|
||||||
|
highlight: function (value) {
|
||||||
|
// Set if an argument was provided
|
||||||
|
if (arguments.length > 0) {
|
||||||
|
highlight = value;
|
||||||
|
}
|
||||||
|
// Provide current value
|
||||||
|
return highlight;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get (or set, if an argument is provided) the drag
|
||||||
|
* highlight state for this swimlane. True means the body
|
||||||
|
* of the swimlane should be highlighted for drop after.
|
||||||
|
*/
|
||||||
|
highlightBottom: function (value) {
|
||||||
|
// Set if an argument was provided
|
||||||
|
if (arguments.length > 0) {
|
||||||
|
highlightBottom = value;
|
||||||
|
}
|
||||||
|
// Provide current value
|
||||||
|
return highlightBottom;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Check if a swimlane exceeds the bounds of its parent.
|
||||||
|
* @returns {boolean} true if there is a bounds violation
|
||||||
|
*/
|
||||||
|
exceeded: function () {
|
||||||
|
var parentTimespan = parent && parent.timespan();
|
||||||
|
return timespan && parentTimespan &&
|
||||||
|
(timespan.getStart() < parentTimespan.getStart() ||
|
||||||
|
timespan.getEnd() > parentTimespan.getEnd());
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the timespan associated with this swimlane
|
||||||
|
*/
|
||||||
|
timespan: function () {
|
||||||
|
return timespan;
|
||||||
|
},
|
||||||
|
// Expose domain object, expansion state, indentation depth
|
||||||
|
domainObject: domainObject,
|
||||||
|
expanded: true,
|
||||||
|
depth: depth,
|
||||||
|
path: path,
|
||||||
|
id: id,
|
||||||
|
idPath: idPath,
|
||||||
|
parent: parent,
|
||||||
|
index: index,
|
||||||
|
children: [] // Populated by populator
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineSwimlane;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,93 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./TimelineSwimlaneDropHandler'],
|
||||||
|
function (TimelineSwimlaneDropHandler) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var ACTIVITY_RELATIONSHIP = "modes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds optional methods to TimelineSwimlanes, in order
|
||||||
|
* to conditionally make available options in the toolbar.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineSwimlaneDecorator(swimlane, selection) {
|
||||||
|
var domainObject = swimlane && swimlane.domainObject,
|
||||||
|
model = (domainObject && domainObject.getModel()) || {},
|
||||||
|
mutator = domainObject && domainObject.getCapability('mutation'),
|
||||||
|
persister = domainObject && domainObject.getCapability('persistence'),
|
||||||
|
type = domainObject && domainObject.getCapability('type'),
|
||||||
|
dropHandler = new TimelineSwimlaneDropHandler(swimlane);
|
||||||
|
|
||||||
|
// Activity Modes dialog
|
||||||
|
function modes(value) {
|
||||||
|
// Can be used as a setter...
|
||||||
|
if (arguments.length > 0 && Array.isArray(value)) {
|
||||||
|
// Update the relationships
|
||||||
|
mutator.mutate(function (model) {
|
||||||
|
model.relationships = model.relationships || {};
|
||||||
|
model.relationships[ACTIVITY_RELATIONSHIP] = value;
|
||||||
|
}).then(persister.persist);
|
||||||
|
}
|
||||||
|
// ...otherwise, use as a getter
|
||||||
|
return (model.relationships || {})[ACTIVITY_RELATIONSHIP] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity Link dialog
|
||||||
|
function link(value) {
|
||||||
|
// Can be used as a setter...
|
||||||
|
if (arguments.length > 0 && (typeof value === 'string') &&
|
||||||
|
value !== model.link) {
|
||||||
|
// Update the link
|
||||||
|
mutator.mutate(function (model) {
|
||||||
|
model.link = value;
|
||||||
|
}).then(persister.persist);
|
||||||
|
}
|
||||||
|
return model.link;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire the Remove action
|
||||||
|
function remove() {
|
||||||
|
return domainObject.getCapability("action").perform("remove");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select the current swimlane
|
||||||
|
function select() {
|
||||||
|
selection.select(swimlane);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the swimlane is selected
|
||||||
|
function selected() {
|
||||||
|
return selection.get() === swimlane;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activities should have the Activity Modes and Activity Link dialog
|
||||||
|
if (type && type.instanceOf("warp.activity") && mutator && persister) {
|
||||||
|
swimlane.modes = modes;
|
||||||
|
swimlane.link = link;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything but the top-level object should have Remove
|
||||||
|
if (swimlane.parent) {
|
||||||
|
swimlane.remove = remove;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're in edit mode, if a selection is available
|
||||||
|
if (selection) {
|
||||||
|
// Add shorthands to select, and check for selection
|
||||||
|
swimlane.select = select;
|
||||||
|
swimlane.selected = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose drop handlers (which needed a reference to the swimlane)
|
||||||
|
swimlane.allowDropIn = dropHandler.allowDropIn;
|
||||||
|
swimlane.allowDropAfter = dropHandler.allowDropAfter;
|
||||||
|
swimlane.drop = dropHandler.drop;
|
||||||
|
|
||||||
|
return swimlane;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineSwimlaneDecorator;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,186 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles drop (from drag-and-drop) initiated changes to a swimlane.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineSwimlaneDropHandler(swimlane) {
|
||||||
|
// Utility function; like $q.when, but synchronous (to reduce
|
||||||
|
// performance impact when wrapping synchronous values)
|
||||||
|
function asPromise(value) {
|
||||||
|
return (value && value.then) ? value : {
|
||||||
|
then: function (callback) {
|
||||||
|
return asPromise(callback(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we are in edit mode
|
||||||
|
function inEditMode() {
|
||||||
|
return swimlane.domainObject.hasCapability("editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean and (for reduce below)
|
||||||
|
function or(a, b) {
|
||||||
|
return a || b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pathA entirely contains pathB
|
||||||
|
function pathContains(swimlane, id) {
|
||||||
|
// Check if id at a specific index matches (for map below)
|
||||||
|
function matches(pathId) {
|
||||||
|
return pathId === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path A contains Path B if it is longer, and all of
|
||||||
|
// B's ids match the ids in A.
|
||||||
|
return swimlane.idPath.map(matches).reduce(or, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a swimlane contains a child with the specified id
|
||||||
|
function contains(swimlane, id) {
|
||||||
|
// Check if a child swimlane has a matching domain object id
|
||||||
|
function matches(child) {
|
||||||
|
return child.domainObject.getId() === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find any one child id that matches this id
|
||||||
|
return swimlane.children.map(matches).reduce(or, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a domain object from its current location
|
||||||
|
function remove(domainObject) {
|
||||||
|
return domainObject &&
|
||||||
|
domainObject.getCapability('action').perform('remove');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initiate mutation of a domain object
|
||||||
|
function doMutate(domainObject, mutator) {
|
||||||
|
return asPromise(
|
||||||
|
domainObject.useCapability("mutation", mutator)
|
||||||
|
).then(function () {
|
||||||
|
// Persist the results of mutation
|
||||||
|
var persistence = domainObject.getCapability("persistence");
|
||||||
|
if (persistence) {
|
||||||
|
// Persist the changes
|
||||||
|
persistence.persist();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this swimlane is in a state where a drop-after will
|
||||||
|
// act as a drop-into-at-first position (expanded and non-empty)
|
||||||
|
function expandedForDropInto() {
|
||||||
|
return swimlane.expanded && swimlane.children.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the swimlane is ready to accept a drop-into
|
||||||
|
// (instead of drop-after)
|
||||||
|
function isDropInto() {
|
||||||
|
return swimlane.highlight() || expandedForDropInto();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose an index for insertion in a domain object's composition
|
||||||
|
function chooseTargetIndex(id, offset, composition) {
|
||||||
|
return Math.max(
|
||||||
|
Math.min(
|
||||||
|
(composition || []).indexOf(id) + offset,
|
||||||
|
(composition || []).length
|
||||||
|
),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert an id into target's composition
|
||||||
|
function insert(id, target, indexOffset) {
|
||||||
|
var myId = swimlane.domainObject.getId();
|
||||||
|
return doMutate(target, function (model) {
|
||||||
|
model.composition.splice(
|
||||||
|
chooseTargetIndex(myId, indexOffset, model.composition),
|
||||||
|
0,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a compose action is allowed for the object in this
|
||||||
|
// swimlane (we handle the link differently to set the index,
|
||||||
|
// but check for the existence of the action to invole the
|
||||||
|
// relevant policies.)
|
||||||
|
function allowsCompose(swimlane, domainObject) {
|
||||||
|
var actionCapability =
|
||||||
|
swimlane.domainObject.getCapability('action');
|
||||||
|
return actionCapability && actionCapability.getActions({
|
||||||
|
key: 'compose',
|
||||||
|
selectedObject: domainObject
|
||||||
|
}).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Check if a drop-into should be allowed for this swimlane,
|
||||||
|
* for the provided domain object identifier.
|
||||||
|
* @param {string} id identifier for the domain object to be
|
||||||
|
* dropped
|
||||||
|
* @returns {boolean} true if this should be allowed
|
||||||
|
*/
|
||||||
|
allowDropIn: function (id, domainObject) {
|
||||||
|
return inEditMode() &&
|
||||||
|
!pathContains(swimlane, id) &&
|
||||||
|
!contains(swimlane, id) &&
|
||||||
|
allowsCompose(swimlane, domainObject);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Check if a drop-after should be allowed for this swimlane,
|
||||||
|
* for the provided domain object identifier.
|
||||||
|
* @param {string} id identifier for the domain object to be
|
||||||
|
* dropped
|
||||||
|
* @returns {boolean} true if this should be allowed
|
||||||
|
*/
|
||||||
|
allowDropAfter: function (id, domainObject) {
|
||||||
|
var target = expandedForDropInto() ?
|
||||||
|
swimlane : swimlane.parent;
|
||||||
|
return inEditMode() &&
|
||||||
|
target &&
|
||||||
|
!pathContains(target, id) &&
|
||||||
|
allowsCompose(target, domainObject);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Drop the provided domain object into a timeline. This is
|
||||||
|
* provided as a mandatory id, and an optional domain object
|
||||||
|
* instance; if the latter is provided, it will be removed
|
||||||
|
* from its parent before being added. (This is specifically
|
||||||
|
* to support moving Activity objects around within a Timeline.)
|
||||||
|
* @param {string} id the identifier for the domain object
|
||||||
|
* @param {DomainObject} [domainObject] the object itself
|
||||||
|
*/
|
||||||
|
drop: function (id, domainObject) {
|
||||||
|
// Get the desired drop object, and destination index
|
||||||
|
var dropInto = isDropInto(),
|
||||||
|
dropTarget = dropInto ?
|
||||||
|
swimlane.domainObject :
|
||||||
|
swimlane.parent.domainObject,
|
||||||
|
dropIndexOffset = (!dropInto) ? 1 :
|
||||||
|
(swimlane.expanded && swimlane.highlightBottom()) ?
|
||||||
|
Number.NEGATIVE_INFINITY :
|
||||||
|
Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
if (swimlane.highlight() || swimlane.highlightBottom()) {
|
||||||
|
// Remove the domain object from its original location...
|
||||||
|
return asPromise(remove(domainObject)).then(function () {
|
||||||
|
// ...then insert it at its new location.
|
||||||
|
insert(id, dropTarget, dropIndexOffset);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineSwimlaneDropHandler;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,164 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[
|
||||||
|
'./TimelineSwimlane',
|
||||||
|
'./TimelineSwimlaneDecorator',
|
||||||
|
'./TimelineColorAssigner',
|
||||||
|
'./TimelineProxy'
|
||||||
|
],
|
||||||
|
function (
|
||||||
|
TimelineSwimlane,
|
||||||
|
TimelineSwimlaneDecorator,
|
||||||
|
TimelineColorAssigner,
|
||||||
|
TimelineProxy
|
||||||
|
) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates and maintains a list of swimlanes for a given
|
||||||
|
* timeline view.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function TimelineSwimlanePopulator(objectLoader, configuration, selection) {
|
||||||
|
var swimlanes = [],
|
||||||
|
start = Number.POSITIVE_INFINITY,
|
||||||
|
end = Number.NEGATIVE_INFINITY,
|
||||||
|
colors = (configuration.colors || {}),
|
||||||
|
assigner = new TimelineColorAssigner(colors);
|
||||||
|
|
||||||
|
// Track extremes of start/end times
|
||||||
|
function trackStartEnd(timespan) {
|
||||||
|
if (timespan) {
|
||||||
|
start = Math.min(start, timespan.getStart());
|
||||||
|
end = Math.max(end, timespan.getEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add domain object (and its subgraph) in as swimlanes
|
||||||
|
function populateSwimlanes(subgraph, parent, index) {
|
||||||
|
var domainObject = subgraph.domainObject,
|
||||||
|
swimlane;
|
||||||
|
|
||||||
|
// For the recursive step
|
||||||
|
function populate(childSubgraph, index) {
|
||||||
|
populateSwimlanes(childSubgraph, swimlane, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we have a valid object instance...
|
||||||
|
if (domainObject) {
|
||||||
|
// Create the new swimlane
|
||||||
|
swimlane = new TimelineSwimlaneDecorator(new TimelineSwimlane(
|
||||||
|
domainObject,
|
||||||
|
assigner,
|
||||||
|
configuration,
|
||||||
|
parent,
|
||||||
|
index || 0
|
||||||
|
), selection);
|
||||||
|
// Track start & end times of this domain object
|
||||||
|
domainObject.useCapability('timespan').then(trackStartEnd);
|
||||||
|
// Add it to our list
|
||||||
|
swimlanes.push(swimlane);
|
||||||
|
// Fill in parent's children
|
||||||
|
((parent || {}).children || []).push(swimlane);
|
||||||
|
// Add in children
|
||||||
|
subgraph.composition.forEach(populate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore a selection
|
||||||
|
function reselect(path, candidates, depth) {
|
||||||
|
// Next ID on the path
|
||||||
|
var next = path[depth || 0];
|
||||||
|
|
||||||
|
// Ensure a default
|
||||||
|
depth = depth || 0;
|
||||||
|
|
||||||
|
// Search through this layer of candidates to see
|
||||||
|
// if they might contain our selection (based on id path)
|
||||||
|
candidates.forEach(function (swimlane) {
|
||||||
|
// Check if we're on the right path...
|
||||||
|
if (swimlane.id === next) {
|
||||||
|
// Do we still have ids to check?
|
||||||
|
if (depth < path.length - 1) {
|
||||||
|
// Yes, so recursively explore that path
|
||||||
|
reselect(path, swimlane.children, depth + 1);
|
||||||
|
} else {
|
||||||
|
// Nope, we found the object to select
|
||||||
|
selection.select(swimlane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle population of swimlanes
|
||||||
|
function recalculateSwimlanes(domainObject) {
|
||||||
|
function populate(subgraph) {
|
||||||
|
// Cache current selection state during refresh
|
||||||
|
var selected = selection && selection.get(),
|
||||||
|
selectedIdPath = selected && selected.idPath;
|
||||||
|
|
||||||
|
// Clear existing swimlanes
|
||||||
|
swimlanes = [];
|
||||||
|
|
||||||
|
// Build new set of swimlanes
|
||||||
|
populateSwimlanes(subgraph);
|
||||||
|
|
||||||
|
// Restore selection, if there was one
|
||||||
|
if (selectedIdPath && swimlanes.length > 0) {
|
||||||
|
reselect(selectedIdPath, [swimlanes[0]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repopulate swimlanes for this object
|
||||||
|
if (!domainObject) {
|
||||||
|
populate({});
|
||||||
|
} else {
|
||||||
|
objectLoader.load(domainObject, 'timespan').then(populate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the selection proxy as well (for the Add button)
|
||||||
|
if (selection) {
|
||||||
|
selection.proxy(
|
||||||
|
domainObject && new TimelineProxy(domainObject, selection)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure colors are exposed in configuration
|
||||||
|
configuration.colors = colors;
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Update list of swimlanes to match those reachable from this
|
||||||
|
* object.
|
||||||
|
* @param {DomainObject} the timeline being viewed
|
||||||
|
*/
|
||||||
|
populate: recalculateSwimlanes,
|
||||||
|
/**
|
||||||
|
* Get a list of swimlanes for this timeline view.
|
||||||
|
* @returns {TimelineSwimlane[]} current swimlanes
|
||||||
|
*/
|
||||||
|
get: function () {
|
||||||
|
return swimlanes;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the first timestamp in the set of swimlanes.
|
||||||
|
* @returns {number} first timestamp
|
||||||
|
*/
|
||||||
|
start: function () {
|
||||||
|
return start;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get the last timestamp in the set of swimlanes.
|
||||||
|
* @returns {number} first timestamp
|
||||||
|
*/
|
||||||
|
end: function () {
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimelineSwimlanePopulator;
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,20 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define({
|
||||||
|
/**
|
||||||
|
* The string identifier for the data type used for drag-and-drop
|
||||||
|
* composition of domain objects. (e.g. in event.dataTransfer.setData
|
||||||
|
* calls.)
|
||||||
|
*/
|
||||||
|
MCT_DRAG_TYPE: 'mct-domain-object-id',
|
||||||
|
/**
|
||||||
|
* The string identifier for the data type used for drag-and-drop
|
||||||
|
* composition of domain objects, by object instance (passed through
|
||||||
|
* the dndService)
|
||||||
|
*/
|
||||||
|
MCT_EXTENDED_DRAG_TYPE: 'mct-domain-object',
|
||||||
|
/**
|
||||||
|
* String identifier for swimlanes being dragged.
|
||||||
|
*/
|
||||||
|
WARP_SWIMLANE_DRAG_TYPE: 'warp-swimlane'
|
||||||
|
});
|
@ -0,0 +1,47 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./SwimlaneDragConstants'],
|
||||||
|
function (SwimlaneDragConstants) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the `warp-swimlane-drag` directive. When a drag is initiated
|
||||||
|
* form an element with this attribute, the swimlane being dragged
|
||||||
|
* (identified by the value of this attribute, as an Angular expression)
|
||||||
|
* will be exported to the `dndService` as part of the active drag-drop
|
||||||
|
* state.
|
||||||
|
* @param {DndService} dndService drag-and-drop service
|
||||||
|
*/
|
||||||
|
function WARPSwimlaneDrag(dndService) {
|
||||||
|
function link(scope, element, attrs) {
|
||||||
|
// Look up the swimlane from the provided expression
|
||||||
|
function swimlane() {
|
||||||
|
return scope.$eval(attrs.warpSwimlaneDrag);
|
||||||
|
}
|
||||||
|
// When drag starts, publish via dndService
|
||||||
|
element.on('dragstart', function () {
|
||||||
|
dndService.setData(
|
||||||
|
SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE,
|
||||||
|
swimlane()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// When drag ends, clear via dndService
|
||||||
|
element.on('dragend', function () {
|
||||||
|
dndService.removeData(
|
||||||
|
SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Applies to attributes
|
||||||
|
restrict: "A",
|
||||||
|
// Link using above function
|
||||||
|
link: link
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return WARPSwimlaneDrag;
|
||||||
|
}
|
||||||
|
);
|
106
platform/features/timeline/src/directives/WARPSwimlaneDrop.js
Normal file
106
platform/features/timeline/src/directives/WARPSwimlaneDrop.js
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['./SwimlaneDragConstants'],
|
||||||
|
function (SwimlaneDragConstants) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the `warp-swimlane-drop` directive. When a drop occurs
|
||||||
|
* on an element with this attribute, the swimlane targeted by the drop
|
||||||
|
* (identified by the value of this attribute, as an Angular expression)
|
||||||
|
* will receive the dropped domain object (at which point it can handle
|
||||||
|
* the drop, typically by inserting/reordering.)
|
||||||
|
* @param {DndService} dndService drag-and-drop service
|
||||||
|
*/
|
||||||
|
function WARPSwimlaneDrop(dndService) {
|
||||||
|
|
||||||
|
// Handle dragover events
|
||||||
|
function dragOver(e, element, swimlane) {
|
||||||
|
var event = (e || {}).originalEvent || e,
|
||||||
|
height = element[0].offsetHeight,
|
||||||
|
rect = element[0].getBoundingClientRect(),
|
||||||
|
offset = event.pageY - rect.top,
|
||||||
|
dataTransfer = event.dataTransfer,
|
||||||
|
id = dndService.getData(
|
||||||
|
SwimlaneDragConstants.MCT_DRAG_TYPE
|
||||||
|
),
|
||||||
|
draggedObject = dndService.getData(
|
||||||
|
SwimlaneDragConstants.MCT_EXTENDED_DRAG_TYPE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
// TODO: Vary this based on modifier keys
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
// Set the swimlane's drop highlight state; top 75% is
|
||||||
|
// for drop-into, bottom 25% is for drop-after.
|
||||||
|
swimlane.highlight(
|
||||||
|
offset < (height * 0.75) &&
|
||||||
|
swimlane.allowDropIn(id, draggedObject)
|
||||||
|
);
|
||||||
|
swimlane.highlightBottom(
|
||||||
|
offset >= (height * 0.75) &&
|
||||||
|
swimlane.allowDropAfter(id, draggedObject)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Indicate that we will accept the drag
|
||||||
|
if (swimlane.highlight() || swimlane.highlightBottom()) {
|
||||||
|
event.preventDefault(); // Required in Chrome?
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drop events
|
||||||
|
function drop(e, element, swimlane) {
|
||||||
|
var event = (e || {}).originalEvent || e,
|
||||||
|
dataTransfer = event.dataTransfer,
|
||||||
|
id = dataTransfer.getData(
|
||||||
|
SwimlaneDragConstants.MCT_DRAG_TYPE
|
||||||
|
),
|
||||||
|
draggedSwimlane = dndService.getData(
|
||||||
|
SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
// Delegate the drop to the swimlane itself
|
||||||
|
swimlane.drop(id, (draggedSwimlane || {}).domainObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the swimlane highlights
|
||||||
|
swimlane.highlight(false);
|
||||||
|
swimlane.highlightBottom(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function link(scope, element, attrs) {
|
||||||
|
// Lookup swimlane by evaluating this attribute
|
||||||
|
function swimlane() {
|
||||||
|
return scope.$eval(attrs.warpSwimlaneDrop);
|
||||||
|
}
|
||||||
|
// Handle dragover
|
||||||
|
element.on('dragover', function (e) {
|
||||||
|
dragOver(e, element, swimlane());
|
||||||
|
});
|
||||||
|
// Handle drops
|
||||||
|
element.on('drop', function (e) {
|
||||||
|
drop(e, element, swimlane());
|
||||||
|
});
|
||||||
|
// Clear highlights when drag leaves this swimlane
|
||||||
|
element.on('dragleave', function () {
|
||||||
|
swimlane().highlight(false);
|
||||||
|
swimlane().highlightBottom(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Applies to attributes
|
||||||
|
restrict: "A",
|
||||||
|
// Link using above function
|
||||||
|
link: link
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return WARPSwimlaneDrop;
|
||||||
|
}
|
||||||
|
);
|
114
platform/features/timeline/src/services/ObjectLoader.js
Normal file
114
platform/features/timeline/src/services/ObjectLoader.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/*global define*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[],
|
||||||
|
function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ObjectLoader is a utility service for loading subgraphs
|
||||||
|
* of the composition hierarchy, starting at a provided object,
|
||||||
|
* and optionally filtering out objects which fail to meet certain
|
||||||
|
* criteria.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function ObjectLoader($q) {
|
||||||
|
|
||||||
|
// Build up an object containing id->object pairs
|
||||||
|
// for the subset of the graph that is relevant.
|
||||||
|
function loadSubGraph(domainObject, criterion) {
|
||||||
|
var result = { domainObject: domainObject, composition: [] },
|
||||||
|
visiting = {},
|
||||||
|
filter;
|
||||||
|
|
||||||
|
// Check object existence (for criterion-less filtering)
|
||||||
|
function exists(domainObject) {
|
||||||
|
return !!domainObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for capability matching criterion
|
||||||
|
function hasCapability(domainObject) {
|
||||||
|
return domainObject && domainObject.hasCapability(criterion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For the recursive step...
|
||||||
|
function loadSubGraphFor(childObject) {
|
||||||
|
return loadSubGraph(childObject, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store loaded subgraphs into the result
|
||||||
|
function storeSubgraphs(subgraphs) {
|
||||||
|
result.composition = subgraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid infinite recursion
|
||||||
|
function notVisiting(domainObject) {
|
||||||
|
return !visiting[domainObject.getId()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put the composition of this domain object into the result
|
||||||
|
function mapIntoResult(composition) {
|
||||||
|
return $q.all(
|
||||||
|
composition.filter(filter).filter(notVisiting)
|
||||||
|
.map(loadSubGraphFor)
|
||||||
|
).then(storeSubgraphs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to give the final result after promise chaining
|
||||||
|
function giveResult() {
|
||||||
|
// Stop suppressing recursive visitation
|
||||||
|
visiting[domainObject.getId()] = true;
|
||||||
|
// And return the expecting result value
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load composition for
|
||||||
|
function loadComposition() {
|
||||||
|
// First, record that we're looking at this domain
|
||||||
|
// object to detect cycles and avoid an infinite loop
|
||||||
|
visiting[domainObject.getId()] = true;
|
||||||
|
// Look up the composition, store it to the graph structure
|
||||||
|
return domainObject.useCapability('composition')
|
||||||
|
.then(mapIntoResult)
|
||||||
|
.then(giveResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose the filter function to use
|
||||||
|
filter = typeof criterion === 'function' ? criterion :
|
||||||
|
(typeof criterion === 'string' ? hasCapability :
|
||||||
|
exists);
|
||||||
|
|
||||||
|
// Load child hierarchy, then provide the flat list
|
||||||
|
return domainObject.hasCapability('composition') ?
|
||||||
|
loadComposition() : $q.when(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Load domain objects contained in the subgraph of the
|
||||||
|
* composition hierarchy which starts at the specified
|
||||||
|
* domain object, optionally pruning out objects (and their
|
||||||
|
* subgraphs) which match a certain criterion.
|
||||||
|
* The result is given as a promise for an object containing
|
||||||
|
* key-value pairs, where keys are domain object identifiers
|
||||||
|
* and values are domain objects in the subgraph.
|
||||||
|
* The criterion may be omitted (in which case no pruning is
|
||||||
|
* done) or specified as a string, in which case it will be
|
||||||
|
* treated as the name of a required capability, or specified
|
||||||
|
* as a function, which should return a truthy/falsy value
|
||||||
|
* when called with a domain object to indicate whether or
|
||||||
|
* not it should be included in the result set.
|
||||||
|
*
|
||||||
|
* @param {DomainObject} domainObject the domain object to
|
||||||
|
* start from
|
||||||
|
* @param {string|Function} [criterion] the criterion used
|
||||||
|
* to prune domain objects
|
||||||
|
* @returns {Promise} a promise for loaded domain objects
|
||||||
|
*/
|
||||||
|
load: loadSubGraph
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ObjectLoader;
|
||||||
|
}
|
||||||
|
);
|
14
platform/features/timeline/test/TimelineConstantsSpec.js
Normal file
14
platform/features/timeline/test/TimelineConstantsSpec.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../src/TimelineConstants'],
|
||||||
|
function (TimelineConstants) {
|
||||||
|
"use strict";
|
||||||
|
describe("The set of Timeline constants", function () {
|
||||||
|
it("specifies a handle width", function () {
|
||||||
|
expect(TimelineConstants.HANDLE_WIDTH)
|
||||||
|
.toEqual(jasmine.any(Number));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
41
platform/features/timeline/test/TimelineFormatterSpec.js
Normal file
41
platform/features/timeline/test/TimelineFormatterSpec.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../src/TimelineFormatter'],
|
||||||
|
function (TimelineFormatter) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var SECOND = 1000,
|
||||||
|
MINUTE = SECOND * 60,
|
||||||
|
HOUR = MINUTE * 60,
|
||||||
|
DAY = HOUR * 24;
|
||||||
|
|
||||||
|
describe("The timeline formatter", function () {
|
||||||
|
var formatter;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
formatter = new TimelineFormatter();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats durations with seconds", function () {
|
||||||
|
expect(formatter.format(SECOND)).toEqual("000 00:00:01.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats durations with milliseconds", function () {
|
||||||
|
expect(formatter.format(SECOND + 42)).toEqual("000 00:00:01.042");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats durations with days", function () {
|
||||||
|
expect(formatter.format(3 * DAY + SECOND)).toEqual("003 00:00:01.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats durations with hours", function () {
|
||||||
|
expect(formatter.format(DAY + HOUR * 11 + SECOND)).toEqual("001 11:00:01.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats durations with minutes", function () {
|
||||||
|
expect(formatter.format(HOUR + MINUTE * 21)).toEqual("000 01:21:00.000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,71 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/ActivityTimespanCapability'],
|
||||||
|
function (ActivityTimespanCapability) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("An Activity's timespan capability", function () {
|
||||||
|
var mockQ,
|
||||||
|
mockDomainObject,
|
||||||
|
capability;
|
||||||
|
|
||||||
|
function asPromise(v) {
|
||||||
|
return (v || {}).then ? v : {
|
||||||
|
then: function (callback) {
|
||||||
|
return asPromise(callback(v));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockQ = jasmine.createSpyObj('$q', ['when']);
|
||||||
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getModel', 'getCapability' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
mockQ.when.andCallFake(asPromise);
|
||||||
|
mockDomainObject.getModel.andReturn({
|
||||||
|
start: {
|
||||||
|
timestamp: 42000,
|
||||||
|
epoch: "TEST"
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
timestamp: 12321
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
capability = new ActivityTimespanCapability(
|
||||||
|
mockQ,
|
||||||
|
mockDomainObject
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies only to activity objects", function () {
|
||||||
|
expect(ActivityTimespanCapability.appliesTo({
|
||||||
|
type: 'warp.activity'
|
||||||
|
})).toBeTruthy();
|
||||||
|
expect(ActivityTimespanCapability.appliesTo({
|
||||||
|
type: 'folder'
|
||||||
|
})).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides timespan based on model", function () {
|
||||||
|
var mockCallback = jasmine.createSpy('callback');
|
||||||
|
capability.invoke().then(mockCallback);
|
||||||
|
// We verify other methods in ActivityTimespanSpec,
|
||||||
|
// so just make sure we got something that looks right.
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith({
|
||||||
|
getStart: jasmine.any(Function),
|
||||||
|
getEnd: jasmine.any(Function),
|
||||||
|
getDuration: jasmine.any(Function),
|
||||||
|
setStart: jasmine.any(Function),
|
||||||
|
setEnd: jasmine.any(Function),
|
||||||
|
setDuration: jasmine.any(Function),
|
||||||
|
getEpoch: jasmine.any(Function)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,80 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/ActivityTimespan'],
|
||||||
|
function (ActivityTimespan) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("An Activity's timespan", function () {
|
||||||
|
var testModel,
|
||||||
|
mutatorModel,
|
||||||
|
mockMutation,
|
||||||
|
timespan;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
testModel = {
|
||||||
|
start: {
|
||||||
|
timestamp: 42000,
|
||||||
|
epoch: "TEST"
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
timestamp: 12321
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Provide a cloned model for mutation purposes
|
||||||
|
// It is important to distinguish mutation made to
|
||||||
|
// the model provided via the mutation capability from
|
||||||
|
// changes made to the model directly (the latter is
|
||||||
|
// not intended usage.)
|
||||||
|
mutatorModel = JSON.parse(JSON.stringify(testModel));
|
||||||
|
mockMutation = jasmine.createSpyObj("mutation", ["mutate"]);
|
||||||
|
mockMutation.mutate.andCallFake(function (mutator) {
|
||||||
|
mutator(mutatorModel);
|
||||||
|
});
|
||||||
|
timespan = new ActivityTimespan(testModel, mockMutation);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides a start time", function () {
|
||||||
|
expect(timespan.getStart()).toEqual(42000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides an end time", function () {
|
||||||
|
expect(timespan.getEnd()).toEqual(54321);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides duration", function () {
|
||||||
|
expect(timespan.getDuration()).toEqual(12321);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides an epoch", function () {
|
||||||
|
expect(timespan.getEpoch()).toEqual("TEST");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets start time using mutation capability", function () {
|
||||||
|
timespan.setStart(52000);
|
||||||
|
expect(mutatorModel.start.timestamp).toEqual(52000);
|
||||||
|
// Should have also changed duration to preserve end
|
||||||
|
expect(mutatorModel.duration.timestamp).toEqual(2321);
|
||||||
|
// Original model should still be the same
|
||||||
|
expect(testModel.start.timestamp).toEqual(42000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets end time using mutation capability", function () {
|
||||||
|
timespan.setEnd(44000);
|
||||||
|
// Should have also changed duration to preserve end
|
||||||
|
expect(mutatorModel.duration.timestamp).toEqual(2000);
|
||||||
|
// Original model should still be the same
|
||||||
|
expect(testModel.duration.timestamp).toEqual(12321);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets duration using mutation capability", function () {
|
||||||
|
timespan.setDuration(8000);
|
||||||
|
// Should have also changed duration to preserve end
|
||||||
|
expect(mutatorModel.duration.timestamp).toEqual(8000);
|
||||||
|
// Original model should still be the same
|
||||||
|
expect(testModel.duration.timestamp).toEqual(12321);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,20 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/ActivityUtilization'],
|
||||||
|
function (ActivityUtilization) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("An Activity's resource utilization", function () {
|
||||||
|
|
||||||
|
// Placeholder; WTD-918 will implement
|
||||||
|
it("has the expected interface", function () {
|
||||||
|
var utilization = new ActivityUtilization();
|
||||||
|
expect(utilization.getPointCount()).toEqual(jasmine.any(Number));
|
||||||
|
expect(utilization.getDomainValue()).toEqual(jasmine.any(Number));
|
||||||
|
expect(utilization.getRangeValue()).toEqual(jasmine.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,60 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/CostCapability'],
|
||||||
|
function (CostCapability) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A subsystem mode's cost capability", function () {
|
||||||
|
var testModel,
|
||||||
|
capability;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
var mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getModel', 'getId' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
testModel = {
|
||||||
|
resources: {
|
||||||
|
abc: -1,
|
||||||
|
power: 12321,
|
||||||
|
comms: 42
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDomainObject.getModel.andReturn(testModel);
|
||||||
|
|
||||||
|
capability = new CostCapability(mockDomainObject);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides a list of resource types", function () {
|
||||||
|
expect(capability.resources())
|
||||||
|
.toEqual(['abc', 'comms', 'power']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides resource costs", function () {
|
||||||
|
expect(capability.cost('abc')).toEqual(-1);
|
||||||
|
expect(capability.cost('power')).toEqual(12321);
|
||||||
|
expect(capability.cost('comms')).toEqual(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides all resources in a group", function () {
|
||||||
|
expect(capability.invoke()).toEqual(testModel.resources);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies to subsystem modes", function () {
|
||||||
|
expect(CostCapability.appliesTo({
|
||||||
|
type: "warp.mode"
|
||||||
|
})).toBeTruthy();
|
||||||
|
expect(CostCapability.appliesTo({
|
||||||
|
type: "warp.activity"
|
||||||
|
})).toBeFalsy();
|
||||||
|
expect(CostCapability.appliesTo({
|
||||||
|
type: "warp.other"
|
||||||
|
})).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,67 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/CumulativeGraph'],
|
||||||
|
function (CumulativeGraph) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A cumulative resource graph", function () {
|
||||||
|
var mockGraph,
|
||||||
|
points,
|
||||||
|
graph;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
points = [ 0, 10, -10, -100, 20, 100, 0 ];
|
||||||
|
|
||||||
|
mockGraph = jasmine.createSpyObj(
|
||||||
|
'graph',
|
||||||
|
[ 'getPointCount', 'getDomainValue', 'getRangeValue' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
mockGraph.getPointCount.andReturn(points.length * 2);
|
||||||
|
mockGraph.getDomainValue.andCallFake(function (i) {
|
||||||
|
return Math.floor(i / 2) * 100 + 25;
|
||||||
|
});
|
||||||
|
mockGraph.getRangeValue.andCallFake(function (i) {
|
||||||
|
return points[Math.floor(i / 2) + i % 2];
|
||||||
|
});
|
||||||
|
|
||||||
|
graph = new CumulativeGraph(
|
||||||
|
mockGraph,
|
||||||
|
1000,
|
||||||
|
2000,
|
||||||
|
1500,
|
||||||
|
1 / 10
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates its wrapped instantaneous graph", function () {
|
||||||
|
// Note that range values are percentages
|
||||||
|
expect(graph.getDomainValue(0)).toEqual(0);
|
||||||
|
expect(graph.getRangeValue(0)).toEqual(50); // initial state
|
||||||
|
expect(graph.getDomainValue(1)).toEqual(25);
|
||||||
|
expect(graph.getRangeValue(1)).toEqual(50); // initial state
|
||||||
|
expect(graph.getDomainValue(2)).toEqual(125);
|
||||||
|
expect(graph.getRangeValue(2)).toEqual(60); // +10
|
||||||
|
expect(graph.getDomainValue(3)).toEqual(225);
|
||||||
|
expect(graph.getRangeValue(3)).toEqual(50); // -10
|
||||||
|
expect(graph.getDomainValue(4)).toEqual(275);
|
||||||
|
expect(graph.getRangeValue(4)).toEqual(0); // -100 (hit bottom)
|
||||||
|
expect(graph.getDomainValue(5)).toEqual(325);
|
||||||
|
expect(graph.getRangeValue(5)).toEqual(0); // still at 0...
|
||||||
|
expect(graph.getDomainValue(6)).toEqual(425);
|
||||||
|
expect(graph.getRangeValue(6)).toEqual(20); // +20
|
||||||
|
expect(graph.getDomainValue(7)).toEqual(505);
|
||||||
|
expect(graph.getRangeValue(7)).toEqual(100); // +100
|
||||||
|
expect(graph.getDomainValue(8)).toEqual(525);
|
||||||
|
expect(graph.getRangeValue(8)).toEqual(100); // still full
|
||||||
|
expect(graph.getDomainValue(9)).toEqual(625);
|
||||||
|
expect(graph.getRangeValue(9)).toEqual(100); // still full
|
||||||
|
expect(graph.getPointCount()).toEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,98 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/GraphCapability'],
|
||||||
|
function (GraphCapability) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A Timeline's graph capability", function () {
|
||||||
|
var mockQ,
|
||||||
|
mockDomainObject,
|
||||||
|
testModel,
|
||||||
|
capability;
|
||||||
|
|
||||||
|
function asPromise(v) {
|
||||||
|
return (v || {}).then ? v : {
|
||||||
|
then: function (cb) {
|
||||||
|
return asPromise(cb(v));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockQ = jasmine.createSpyObj('$q', ['when']);
|
||||||
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getId', 'getModel', 'useCapability' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
testModel = {
|
||||||
|
type: "warp.activity",
|
||||||
|
resources: {
|
||||||
|
abc: 100,
|
||||||
|
xyz: 42
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockQ.when.andCallFake(asPromise);
|
||||||
|
mockDomainObject.getModel.andReturn(testModel);
|
||||||
|
|
||||||
|
capability = new GraphCapability(
|
||||||
|
mockQ,
|
||||||
|
mockDomainObject
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is applicable to timelines", function () {
|
||||||
|
expect(GraphCapability.appliesTo({
|
||||||
|
type: "warp.timeline"
|
||||||
|
})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is applicable to activities", function () {
|
||||||
|
expect(GraphCapability.appliesTo(testModel))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not applicable to other objects", function () {
|
||||||
|
expect(GraphCapability.appliesTo({
|
||||||
|
type: "something"
|
||||||
|
})).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides one graph per resource type", function () {
|
||||||
|
var mockCallback = jasmine.createSpy('callback');
|
||||||
|
|
||||||
|
mockDomainObject.useCapability.andReturn(asPromise([
|
||||||
|
{ key: "abc", start: 0, end: 15 },
|
||||||
|
{ key: "abc", start: 0, end: 15 },
|
||||||
|
{ key: "def", start: 4, end: 15 },
|
||||||
|
{ key: "xyz", start: 0, end: 20 }
|
||||||
|
]));
|
||||||
|
|
||||||
|
capability.invoke().then(mockCallback);
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith({
|
||||||
|
abc: jasmine.any(Object),
|
||||||
|
def: jasmine.any(Object),
|
||||||
|
xyz: jasmine.any(Object)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides a battery graph for timelines with capacity", function () {
|
||||||
|
var mockCallback = jasmine.createSpy('callback');
|
||||||
|
testModel.capacity = 1000;
|
||||||
|
testModel.type = "warp.timeline";
|
||||||
|
mockDomainObject.useCapability.andReturn(asPromise([
|
||||||
|
{ key: "power", start: 0, end: 15 }
|
||||||
|
]));
|
||||||
|
capability.invoke().then(mockCallback);
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith({
|
||||||
|
power: jasmine.any(Object),
|
||||||
|
battery: jasmine.any(Object)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,56 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/ResourceGraph'],
|
||||||
|
function (ResourceGraph) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A resource graph capability", function () {
|
||||||
|
|
||||||
|
// Placeholder; WTD-918 will implement
|
||||||
|
it("has zero points for zero utilization changes", function () {
|
||||||
|
var graph = new ResourceGraph([]);
|
||||||
|
expect(graph.getPointCount()).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates steps based on resource utilizations", function () {
|
||||||
|
var graph = new ResourceGraph([
|
||||||
|
{ start: 5, end: 100, value: 42 },
|
||||||
|
{ start: 50, end: 120, value: -22 },
|
||||||
|
{ start: 15, end: 40, value: 30 },
|
||||||
|
{ start: 150, end: 180, value: -10 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(graph.getPointCount()).toEqual(16);
|
||||||
|
|
||||||
|
// Should get two values at every time stamp, for step-like appearance
|
||||||
|
[ 5, 15, 40, 50, 100, 120, 150, 180].forEach(function (v, i) {
|
||||||
|
expect(graph.getDomainValue(i * 2)).toEqual(v);
|
||||||
|
expect(graph.getDomainValue(i * 2 + 1)).toEqual(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should also repeat values at subsequent indexes, but offset differently,
|
||||||
|
// for horizontal spans between steps
|
||||||
|
[ 0, 42, 72, 42, 20, -22, 0, -10].forEach(function (v, i) {
|
||||||
|
expect(graph.getRangeValue(i * 2)).toEqual(v);
|
||||||
|
// Offset backwards; wrap around end of the series
|
||||||
|
expect(graph.getRangeValue((16 + i * 2 - 1) % 16)).toEqual(v);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out zero-duration spikes", function () {
|
||||||
|
var graph = new ResourceGraph([
|
||||||
|
{ start: 5, end: 100, value: 42 },
|
||||||
|
{ start: 100, end: 120, value: -22 },
|
||||||
|
{ start: 100, end: 180, value: 30 },
|
||||||
|
{ start: 130, end: 180, value: -10 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// There are only 5 unique timestamps there, so there should
|
||||||
|
// be 5 steps, for 10 total points
|
||||||
|
expect(graph.getPointCount()).toEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,115 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/TimelineTimespanCapability'],
|
||||||
|
function (TimelineTimespanCapability) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A Timeline's timespan capability", function () {
|
||||||
|
var mockQ,
|
||||||
|
mockDomainObject,
|
||||||
|
mockChildA,
|
||||||
|
mockChildB,
|
||||||
|
mockTimespanA,
|
||||||
|
mockTimespanB,
|
||||||
|
capability;
|
||||||
|
|
||||||
|
function asPromise(v) {
|
||||||
|
return (v || {}).then ? v : {
|
||||||
|
then: function (callback) {
|
||||||
|
return asPromise(callback(v));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockQ = jasmine.createSpyObj('$q', ['when', 'all']);
|
||||||
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getModel', 'getCapability', 'useCapability' ]
|
||||||
|
);
|
||||||
|
mockChildA = jasmine.createSpyObj(
|
||||||
|
'childA',
|
||||||
|
[ 'getModel', 'useCapability', 'hasCapability' ]
|
||||||
|
);
|
||||||
|
mockChildB = jasmine.createSpyObj(
|
||||||
|
'childA',
|
||||||
|
[ 'getModel', 'useCapability', 'hasCapability' ]
|
||||||
|
);
|
||||||
|
mockTimespanA = jasmine.createSpyObj(
|
||||||
|
'timespanA',
|
||||||
|
[ 'getEnd' ]
|
||||||
|
);
|
||||||
|
mockTimespanB = jasmine.createSpyObj(
|
||||||
|
'timespanB',
|
||||||
|
[ 'getEnd' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
mockQ.when.andCallFake(asPromise);
|
||||||
|
mockQ.all.andCallFake(function (values) {
|
||||||
|
var result = [];
|
||||||
|
function addResult(v) { result.push(v); }
|
||||||
|
function promiseResult(v) { asPromise(v).then(addResult); }
|
||||||
|
values.forEach(promiseResult);
|
||||||
|
return asPromise(result);
|
||||||
|
});
|
||||||
|
mockDomainObject.getModel.andReturn({
|
||||||
|
start: {
|
||||||
|
timestamp: 42000,
|
||||||
|
epoch: "TEST"
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
timestamp: 12321
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mockDomainObject.useCapability.andCallFake(function (c) {
|
||||||
|
if (c === 'composition') {
|
||||||
|
return asPromise([ mockChildA, mockChildB ]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mockChildA.hasCapability.andReturn(true);
|
||||||
|
mockChildB.hasCapability.andReturn(true);
|
||||||
|
mockChildA.useCapability.andCallFake(function (c) {
|
||||||
|
return c === 'timespan' && mockTimespanA;
|
||||||
|
});
|
||||||
|
mockChildB.useCapability.andCallFake(function (c) {
|
||||||
|
return c === 'timespan' && mockTimespanB;
|
||||||
|
});
|
||||||
|
|
||||||
|
capability = new TimelineTimespanCapability(
|
||||||
|
mockQ,
|
||||||
|
mockDomainObject
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies only to timeline objects", function () {
|
||||||
|
expect(TimelineTimespanCapability.appliesTo({
|
||||||
|
type: 'warp.timeline'
|
||||||
|
})).toBeTruthy();
|
||||||
|
expect(TimelineTimespanCapability.appliesTo({
|
||||||
|
type: 'folder'
|
||||||
|
})).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides timespan based on model", function () {
|
||||||
|
var mockCallback = jasmine.createSpy('callback');
|
||||||
|
capability.invoke().then(mockCallback);
|
||||||
|
// We verify other methods in ActivityTimespanSpec,
|
||||||
|
// so just make sure we got something that looks right.
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith({
|
||||||
|
getStart: jasmine.any(Function),
|
||||||
|
getEnd: jasmine.any(Function),
|
||||||
|
getDuration: jasmine.any(Function),
|
||||||
|
setStart: jasmine.any(Function),
|
||||||
|
setEnd: jasmine.any(Function),
|
||||||
|
setDuration: jasmine.any(Function),
|
||||||
|
getEpoch: jasmine.any(Function)
|
||||||
|
});
|
||||||
|
// Finally, verify that getEnd recurses
|
||||||
|
mockCallback.mostRecentCall.args[0].getEnd();
|
||||||
|
expect(mockTimespanA.getEnd).toHaveBeenCalled();
|
||||||
|
expect(mockTimespanB.getEnd).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,91 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/TimelineTimespan'],
|
||||||
|
function (TimelineTimespan) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A Timeline's timespan", function () {
|
||||||
|
var testModel,
|
||||||
|
mockTimespans,
|
||||||
|
mockMutation,
|
||||||
|
mutationModel,
|
||||||
|
timespan;
|
||||||
|
|
||||||
|
function makeMockTimespan(end) {
|
||||||
|
var mockTimespan = jasmine.createSpyObj(
|
||||||
|
'timespan-' + end,
|
||||||
|
['getEnd']
|
||||||
|
);
|
||||||
|
mockTimespan.getEnd.andReturn(end);
|
||||||
|
return mockTimespan;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
testModel = {
|
||||||
|
start: {
|
||||||
|
timestamp: 42000,
|
||||||
|
epoch: "TEST"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mutationModel = JSON.parse(JSON.stringify(testModel));
|
||||||
|
|
||||||
|
mockMutation = jasmine.createSpyObj("mutation", ["mutate"]);
|
||||||
|
mockTimespans = [ 44000, 65000, 1100 ].map(makeMockTimespan);
|
||||||
|
|
||||||
|
mockMutation.mutate.andCallFake(function (mutator) {
|
||||||
|
mutator(mutationModel);
|
||||||
|
});
|
||||||
|
|
||||||
|
timespan = new TimelineTimespan(
|
||||||
|
testModel,
|
||||||
|
mockMutation,
|
||||||
|
mockTimespans
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides a start time", function () {
|
||||||
|
expect(timespan.getStart()).toEqual(42000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides an end time", function () {
|
||||||
|
expect(timespan.getEnd()).toEqual(65000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides duration", function () {
|
||||||
|
expect(timespan.getDuration()).toEqual(65000 - 42000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides an epoch", function () {
|
||||||
|
expect(timespan.getEpoch()).toEqual("TEST");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("sets start time using mutation capability", function () {
|
||||||
|
timespan.setStart(52000);
|
||||||
|
expect(mutationModel.start.timestamp).toEqual(52000);
|
||||||
|
// Original model should still be the same
|
||||||
|
expect(testModel.start.timestamp).toEqual(42000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes no changes with setEnd", function () {
|
||||||
|
// Copy initial state to verify that it doesn't change
|
||||||
|
var initialModel = JSON.parse(JSON.stringify(testModel));
|
||||||
|
timespan.setEnd(123454321);
|
||||||
|
// Neither model should have changed
|
||||||
|
expect(testModel).toEqual(initialModel);
|
||||||
|
expect(mutationModel).toEqual(initialModel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes no changes with setDuration", function () {
|
||||||
|
// Copy initial state to verify that it doesn't change
|
||||||
|
var initialModel = JSON.parse(JSON.stringify(testModel));
|
||||||
|
timespan.setDuration(123454321);
|
||||||
|
// Neither model should have changed
|
||||||
|
expect(testModel).toEqual(initialModel);
|
||||||
|
expect(mutationModel).toEqual(initialModel);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,20 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/TimelineUtilization'],
|
||||||
|
function (TimelineUtilization) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A Timeline's resource utilization", function () {
|
||||||
|
|
||||||
|
// Placeholder; WTD-918 will implement
|
||||||
|
it("has the expected interface", function () {
|
||||||
|
var utilization = new TimelineUtilization();
|
||||||
|
expect(utilization.getPointCount()).toEqual(jasmine.any(Number));
|
||||||
|
expect(utilization.getDomainValue()).toEqual(jasmine.any(Number));
|
||||||
|
expect(utilization.getRangeValue()).toEqual(jasmine.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,195 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/capabilities/UtilizationCapability'],
|
||||||
|
function (UtilizationCapability) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A Timeline's utilization capability", function () {
|
||||||
|
var mockQ,
|
||||||
|
mockDomainObject,
|
||||||
|
testModel,
|
||||||
|
testCapabilities,
|
||||||
|
mockRelationship,
|
||||||
|
mockComposition,
|
||||||
|
mockCallback,
|
||||||
|
capability;
|
||||||
|
|
||||||
|
function asPromise(v) {
|
||||||
|
return (v || {}).then ? v : {
|
||||||
|
then: function (callback) {
|
||||||
|
return asPromise(callback(v));
|
||||||
|
},
|
||||||
|
testValue: v
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function allPromises(promises) {
|
||||||
|
return asPromise(promises.map(function (p) {
|
||||||
|
return (p || {}).then ? p.testValue : p;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function for making domain objects with utilization
|
||||||
|
// and/or cost capabilities
|
||||||
|
function fakeDomainObject(resources, start, end, costs) {
|
||||||
|
return {
|
||||||
|
getCapability: function (c) {
|
||||||
|
return ((c === 'utilization') && {
|
||||||
|
// Utilization capability
|
||||||
|
resources: function () {
|
||||||
|
return asPromise(resources);
|
||||||
|
},
|
||||||
|
invoke: function () {
|
||||||
|
return asPromise(resources.map(function (k) {
|
||||||
|
return { key: k, start: start, end: end };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}) || ((c === 'cost') && {
|
||||||
|
// Cost capability
|
||||||
|
resources: function () {
|
||||||
|
return Object.keys(costs).sort();
|
||||||
|
},
|
||||||
|
cost: function (c) {
|
||||||
|
return costs[c];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
useCapability: function (c) {
|
||||||
|
return this.getCapability(c).invoke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockQ = jasmine.createSpyObj('$q', ['when', 'all']);
|
||||||
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getId', 'getModel', 'getCapability', 'useCapability' ]
|
||||||
|
);
|
||||||
|
mockRelationship = jasmine.createSpyObj(
|
||||||
|
'relationship',
|
||||||
|
[ 'getRelatedObjects' ]
|
||||||
|
);
|
||||||
|
mockComposition = jasmine.createSpyObj(
|
||||||
|
'composition',
|
||||||
|
[ 'invoke' ]
|
||||||
|
);
|
||||||
|
mockCallback = jasmine.createSpy('callback');
|
||||||
|
|
||||||
|
testModel = {
|
||||||
|
type: "warp.activity",
|
||||||
|
resources: {
|
||||||
|
abc: 100,
|
||||||
|
xyz: 42
|
||||||
|
}
|
||||||
|
};
|
||||||
|
testCapabilities = {
|
||||||
|
composition: mockComposition,
|
||||||
|
relationship: mockRelationship
|
||||||
|
};
|
||||||
|
|
||||||
|
mockQ.when.andCallFake(asPromise);
|
||||||
|
mockQ.all.andCallFake(allPromises);
|
||||||
|
mockDomainObject.getModel.andReturn(testModel);
|
||||||
|
mockDomainObject.getCapability.andCallFake(function (c) {
|
||||||
|
return testCapabilities[c];
|
||||||
|
});
|
||||||
|
mockDomainObject.useCapability.andCallFake(function (c) {
|
||||||
|
return testCapabilities[c] && testCapabilities[c].invoke();
|
||||||
|
});
|
||||||
|
|
||||||
|
capability = new UtilizationCapability(
|
||||||
|
mockQ,
|
||||||
|
mockDomainObject
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is applicable to timelines", function () {
|
||||||
|
expect(UtilizationCapability.appliesTo({
|
||||||
|
type: "warp.timeline"
|
||||||
|
})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is applicable to activities", function () {
|
||||||
|
expect(UtilizationCapability.appliesTo(testModel))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not applicable to other objects", function () {
|
||||||
|
expect(UtilizationCapability.appliesTo({
|
||||||
|
type: "something"
|
||||||
|
})).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates resources from composition", function () {
|
||||||
|
mockComposition.invoke.andReturn(asPromise([
|
||||||
|
fakeDomainObject(['abc', 'def']),
|
||||||
|
fakeDomainObject(['def', 'xyz']),
|
||||||
|
fakeDomainObject(['abc', 'xyz'])
|
||||||
|
]));
|
||||||
|
|
||||||
|
capability.resources().then(mockCallback);
|
||||||
|
|
||||||
|
expect(mockCallback)
|
||||||
|
.toHaveBeenCalledWith(['abc', 'def', 'xyz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates utilizations from composition", function () {
|
||||||
|
mockComposition.invoke.andReturn(asPromise([
|
||||||
|
fakeDomainObject(['abc', 'def'], 10, 100),
|
||||||
|
fakeDomainObject(['def', 'xyz'], 50, 90)
|
||||||
|
]));
|
||||||
|
|
||||||
|
capability.invoke().then(mockCallback);
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith([
|
||||||
|
{ key: 'abc', start: 10, end: 100 },
|
||||||
|
{ key: 'def', start: 10, end: 100 },
|
||||||
|
{ key: 'def', start: 50, end: 90 },
|
||||||
|
{ key: 'xyz', start: 50, end: 90 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides intrinsic utilization from related objects", function () {
|
||||||
|
var mockTimespan = jasmine.createSpyObj(
|
||||||
|
'timespan',
|
||||||
|
['getStart', 'getEnd', 'getEpoch']
|
||||||
|
),
|
||||||
|
mockTimespanCapability = jasmine.createSpyObj(
|
||||||
|
'timespanCapability',
|
||||||
|
['invoke']
|
||||||
|
);
|
||||||
|
mockComposition.invoke.andReturn(asPromise([]));
|
||||||
|
mockRelationship.getRelatedObjects.andReturn(asPromise([
|
||||||
|
fakeDomainObject([], 0, 0, { abc: 5, xyz: 15 })
|
||||||
|
]));
|
||||||
|
|
||||||
|
testCapabilities.timespan = mockTimespanCapability;
|
||||||
|
mockTimespanCapability.invoke.andReturn(asPromise(mockTimespan));
|
||||||
|
mockTimespan.getStart.andReturn(42);
|
||||||
|
mockTimespan.getEnd.andReturn(12321);
|
||||||
|
mockTimespan.getEpoch.andReturn("TEST");
|
||||||
|
|
||||||
|
capability.invoke().then(mockCallback);
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith([
|
||||||
|
{ key: 'abc', start: 42, end: 12321, value: 5, epoch: "TEST" },
|
||||||
|
{ key: 'xyz', start: 42, end: 12321, value: 15, epoch: "TEST" }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides resource keys from related objects", function () {
|
||||||
|
mockComposition.invoke.andReturn(asPromise([]));
|
||||||
|
mockRelationship.getRelatedObjects.andReturn(asPromise([
|
||||||
|
fakeDomainObject([], 0, 0, { abc: 5, xyz: 15 })
|
||||||
|
]));
|
||||||
|
|
||||||
|
capability.resources().then(mockCallback);
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith(['abc', 'xyz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,32 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/controllers/ActivityModeValuesController'],
|
||||||
|
function (ActivityModeValuesController) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("An Activity Mode's Values view controller", function () {
|
||||||
|
var testResources,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
testResources = [
|
||||||
|
{ key: 'abc', name: "Some name" },
|
||||||
|
{ key: 'def', name: "Test type", units: "Test units" },
|
||||||
|
{ key: 'xyz', name: "Something else" }
|
||||||
|
];
|
||||||
|
controller = new ActivityModeValuesController(testResources);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes resource metadata by key", function () {
|
||||||
|
expect(controller.metadata('abc')).toEqual(testResources[0]);
|
||||||
|
expect(controller.metadata('def')).toEqual(testResources[1]);
|
||||||
|
expect(controller.metadata('xyz')).toEqual(testResources[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes no metadata for unknown keys", function () {
|
||||||
|
expect(controller.metadata('???')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,229 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/controllers/TimelineController'],
|
||||||
|
function (TimelineController) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var DOMAIN_OBJECT_METHODS = [
|
||||||
|
'getModel',
|
||||||
|
'getId',
|
||||||
|
'useCapability',
|
||||||
|
'hasCapability',
|
||||||
|
'getCapability'
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("The timeline controller", function () {
|
||||||
|
var mockScope,
|
||||||
|
mockQ,
|
||||||
|
mockLoader,
|
||||||
|
mockDomainObject,
|
||||||
|
mockSpan,
|
||||||
|
testModels,
|
||||||
|
testConfiguration,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
function asPromise(v) {
|
||||||
|
return (v || {}).then ? v : {
|
||||||
|
then: function (callback) {
|
||||||
|
return asPromise(callback(v));
|
||||||
|
},
|
||||||
|
testValue: v
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function allPromises(promises) {
|
||||||
|
return asPromise(promises.map(function (p) {
|
||||||
|
return (p || {}).then ? p.testValue : p;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subgraph(domainObject, objects) {
|
||||||
|
function lookupSubgraph(id) {
|
||||||
|
return subgraph(objects[id], objects);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
domainObject: domainObject,
|
||||||
|
composition: (domainObject.getModel().composition || [])
|
||||||
|
.map(lookupSubgraph)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
var mockA, mockB, mockUtilization, mockPromise, mockGraph, testCapabilities;
|
||||||
|
|
||||||
|
function getCapability(c) {
|
||||||
|
return testCapabilities[c];
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCapability(c) {
|
||||||
|
return c === 'timespan' ? asPromise(mockSpan) :
|
||||||
|
c === 'graph' ? asPromise({ abc: mockGraph, xyz: mockGraph }) :
|
||||||
|
undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
testModels = {
|
||||||
|
a: { modified: 40, composition: ['b'] },
|
||||||
|
b: { modified: 2 }
|
||||||
|
};
|
||||||
|
|
||||||
|
testConfiguration = {};
|
||||||
|
|
||||||
|
mockQ = jasmine.createSpyObj('$q', ['when', 'all']);
|
||||||
|
mockA = jasmine.createSpyObj('a', DOMAIN_OBJECT_METHODS);
|
||||||
|
mockB = jasmine.createSpyObj('b', DOMAIN_OBJECT_METHODS);
|
||||||
|
mockSpan = jasmine.createSpyObj('span', ['getStart', 'getEnd']);
|
||||||
|
mockUtilization = jasmine.createSpyObj('utilization', ['resources', 'utilization']);
|
||||||
|
mockGraph = jasmine.createSpyObj('graph', ['getPointCount']);
|
||||||
|
mockPromise = jasmine.createSpyObj('promise', ['then']);
|
||||||
|
|
||||||
|
mockScope = jasmine.createSpyObj(
|
||||||
|
"$scope",
|
||||||
|
[ '$watch', '$on' ]
|
||||||
|
);
|
||||||
|
mockLoader = jasmine.createSpyObj('objectLoader', ['load']);
|
||||||
|
mockDomainObject = mockA;
|
||||||
|
|
||||||
|
mockScope.domainObject = mockDomainObject;
|
||||||
|
mockScope.configuration = testConfiguration;
|
||||||
|
mockQ.when.andCallFake(asPromise);
|
||||||
|
mockQ.all.andCallFake(allPromises);
|
||||||
|
mockA.getId.andReturn('a');
|
||||||
|
mockA.getModel.andReturn(testModels.a);
|
||||||
|
mockB.getId.andReturn('b');
|
||||||
|
mockB.getModel.andReturn(testModels.b);
|
||||||
|
mockA.useCapability.andCallFake(useCapability);
|
||||||
|
mockB.useCapability.andCallFake(useCapability);
|
||||||
|
mockA.hasCapability.andReturn(true);
|
||||||
|
mockB.hasCapability.andReturn(true);
|
||||||
|
mockA.getCapability.andCallFake(getCapability);
|
||||||
|
mockB.getCapability.andCallFake(getCapability);
|
||||||
|
mockSpan.getStart.andReturn(42);
|
||||||
|
mockSpan.getEnd.andReturn(12321);
|
||||||
|
mockUtilization.resources.andReturn(['abc', 'xyz']);
|
||||||
|
mockUtilization.utilization.andReturn(mockPromise);
|
||||||
|
mockLoader.load.andCallFake(function () {
|
||||||
|
return asPromise(subgraph(mockA, {
|
||||||
|
a: mockA,
|
||||||
|
b: mockB
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
testCapabilities = {
|
||||||
|
"utilization": mockUtilization
|
||||||
|
};
|
||||||
|
|
||||||
|
controller = new TimelineController(mockScope, mockQ, mockLoader, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes scroll state tracker in scope", function () {
|
||||||
|
expect(mockScope.scroll.x).toEqual(0);
|
||||||
|
expect(mockScope.scroll.y).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repopulates when modifications are made", function () {
|
||||||
|
var fnWatchCall,
|
||||||
|
strWatchCall;
|
||||||
|
|
||||||
|
// Find the $watch that was given a function
|
||||||
|
mockScope.$watch.calls.forEach(function (call) {
|
||||||
|
if (typeof call.args[0] === 'function') {
|
||||||
|
// white-box: we know the first call is
|
||||||
|
// the one we're looking for
|
||||||
|
fnWatchCall = fnWatchCall || call;
|
||||||
|
} else if (typeof call.args[0] === 'string') {
|
||||||
|
strWatchCall = strWatchCall || call;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure string watch was for domainObject
|
||||||
|
expect(strWatchCall.args[0]).toEqual('domainObject');
|
||||||
|
// Initially populate
|
||||||
|
strWatchCall.args[1](mockDomainObject);
|
||||||
|
|
||||||
|
// There should be to swimlanes
|
||||||
|
expect(controller.swimlanes().length).toEqual(2);
|
||||||
|
|
||||||
|
// Watch should be for sum of modified flags...
|
||||||
|
expect(fnWatchCall.args[0]()).toEqual(42);
|
||||||
|
|
||||||
|
// Remove the child, then fire the watch
|
||||||
|
testModels.a.composition = [];
|
||||||
|
fnWatchCall.args[1]();
|
||||||
|
|
||||||
|
// Swimlanes should have updated
|
||||||
|
expect(controller.swimlanes().length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repopulates graphs when graph choices change", function () {
|
||||||
|
var tmp;
|
||||||
|
|
||||||
|
// Note that this test is brittle; it relies upon the
|
||||||
|
// order of $watch calls in TimelineController.
|
||||||
|
|
||||||
|
// Initially populate
|
||||||
|
mockScope.$watch.calls[0].args[1](mockDomainObject);
|
||||||
|
|
||||||
|
// Verify precondition - no graphs
|
||||||
|
expect(controller.graphs().length).toEqual(0);
|
||||||
|
|
||||||
|
// Execute the watch function for graph state
|
||||||
|
tmp = mockScope.$watch.calls[2].args[0]();
|
||||||
|
|
||||||
|
// Change graph state
|
||||||
|
testConfiguration.graph = { a: true, b: true };
|
||||||
|
|
||||||
|
// Verify that this would have triggered a watch
|
||||||
|
expect(mockScope.$watch.calls[2].args[0]())
|
||||||
|
.not.toEqual(tmp);
|
||||||
|
|
||||||
|
// Run the function the watch would have triggered
|
||||||
|
mockScope.$watch.calls[2].args[1]();
|
||||||
|
|
||||||
|
// Should have some graphs now
|
||||||
|
expect(controller.graphs().length).toEqual(2);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports full scrollable width using zoom controller", function () {
|
||||||
|
var mockZoom = jasmine.createSpyObj('zoom', ['toPixels', 'duration']);
|
||||||
|
mockZoom.toPixels.andReturn(54321);
|
||||||
|
mockZoom.duration.andReturn(12345);
|
||||||
|
|
||||||
|
// Initially populate
|
||||||
|
mockScope.$watch.calls[0].args[1](mockDomainObject);
|
||||||
|
|
||||||
|
expect(controller.width(mockZoom)).toEqual(54321);
|
||||||
|
// Verify interactions; we took zoom's duration for our start/end,
|
||||||
|
// and converted it to pixels.
|
||||||
|
// First, check that we used the start/end (from above)
|
||||||
|
expect(mockZoom.duration).toHaveBeenCalledWith(12321 - 42);
|
||||||
|
// Next, verify that the result was passed to toPixels
|
||||||
|
expect(mockZoom.toPixels).toHaveBeenCalledWith(12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides drag handles", function () {
|
||||||
|
// TimelineDragPopulator et al are tested for these,
|
||||||
|
// so just verify that handles are indeed exposed.
|
||||||
|
expect(controller.handles()).toEqual(jasmine.any(Array));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes graphs on request", function () {
|
||||||
|
var mockGraph = jasmine.createSpyObj('graph', ['refresh']);
|
||||||
|
|
||||||
|
// Sneak a mock graph into the graph populator...
|
||||||
|
// This is whiteboxy and will have to change if
|
||||||
|
// GraphPopulator changes
|
||||||
|
controller.graphs().push(mockGraph);
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
controller.refresh();
|
||||||
|
|
||||||
|
// Should have refreshed the graph
|
||||||
|
expect(mockGraph.refresh).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,80 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/controllers/TimelineGanttController'],
|
||||||
|
function (TimelineGanttController) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var TEST_MAX_OFFSCREEN = 50;
|
||||||
|
|
||||||
|
describe("The timeline Gantt bar controller", function () {
|
||||||
|
var mockTimespan,
|
||||||
|
testScroll,
|
||||||
|
mockToPixels,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
// Shorthands for passing these arguments to the controller
|
||||||
|
function width() {
|
||||||
|
return controller.width(
|
||||||
|
mockTimespan,
|
||||||
|
testScroll,
|
||||||
|
mockToPixels
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function left() {
|
||||||
|
return controller.left(
|
||||||
|
mockTimespan,
|
||||||
|
testScroll,
|
||||||
|
mockToPixels
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockTimespan = jasmine.createSpyObj(
|
||||||
|
'timespan',
|
||||||
|
['getStart', 'getEnd', 'getDuration']
|
||||||
|
);
|
||||||
|
testScroll = { x: 0, width: 2000 };
|
||||||
|
mockToPixels = jasmine.createSpy('toPixels');
|
||||||
|
|
||||||
|
mockTimespan.getStart.andReturn(100);
|
||||||
|
mockTimespan.getDuration.andReturn(50);
|
||||||
|
mockTimespan.getEnd.andReturn(150);
|
||||||
|
|
||||||
|
mockToPixels.andCallFake(function (t) { return t * 10; });
|
||||||
|
|
||||||
|
controller = new TimelineGanttController(TEST_MAX_OFFSCREEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("positions start and end points correctly on-screen", function () {
|
||||||
|
// Test's initial conditions are nominal, so should have
|
||||||
|
// the same return value as mockToPixels
|
||||||
|
expect(left()).toEqual(1000);
|
||||||
|
expect(width()).toEqual(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents excessive off screen values to the left", function () {
|
||||||
|
testScroll.x = 1200;
|
||||||
|
expect(left()).toEqual(1150);
|
||||||
|
expect(width()).toEqual(350); // ...such that right edge is 1500
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents excessive off screen values to the right", function () {
|
||||||
|
testScroll.width = 1200;
|
||||||
|
expect(left()).toEqual(1000);
|
||||||
|
expect(width()).toEqual(250); // ...such that right edge is 1250
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents excessive off screen values on both edges", function () {
|
||||||
|
testScroll.x = 1100;
|
||||||
|
testScroll.width = 200; // Visible right edge is now 1300
|
||||||
|
expect(left()).toEqual(1050);
|
||||||
|
expect(width()).toEqual(300); // ...such that right edge is 1350
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,68 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/controllers/TimelineGraphController'],
|
||||||
|
function (TimelineGraphController) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("The Timeline graph controller", function () {
|
||||||
|
var mockScope,
|
||||||
|
testResources,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockScope = jasmine.createSpyObj(
|
||||||
|
'$scope',
|
||||||
|
[ '$watchCollection' ]
|
||||||
|
);
|
||||||
|
testResources = [
|
||||||
|
{ key: 'abc', name: "Some name" },
|
||||||
|
{ key: 'def', name: "Test type", units: "Test units" },
|
||||||
|
{ key: 'xyz', name: "Something else" }
|
||||||
|
];
|
||||||
|
controller = new TimelineGraphController(
|
||||||
|
mockScope,
|
||||||
|
testResources
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("watches for parameter changes", function () {
|
||||||
|
expect(mockScope.$watchCollection).toHaveBeenCalledWith(
|
||||||
|
'parameters',
|
||||||
|
jasmine.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates graphs when parameters change", function () {
|
||||||
|
var mockGraphA = jasmine.createSpyObj('graph-a', ['setBounds']),
|
||||||
|
mockGraphB = jasmine.createSpyObj('graph-b', ['setBounds']);
|
||||||
|
|
||||||
|
// Supply new parameters
|
||||||
|
mockScope.$watchCollection.mostRecentCall.args[1]({
|
||||||
|
graphs: [ mockGraphA, mockGraphB ],
|
||||||
|
origin: 9,
|
||||||
|
duration: 144
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graphs should have both been updated
|
||||||
|
expect(mockGraphA.setBounds).toHaveBeenCalledWith(9, 144);
|
||||||
|
expect(mockGraphB.setBounds).toHaveBeenCalledWith(9, 144);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides labels for graphs", function () {
|
||||||
|
var mockGraph = jasmine.createSpyObj('graph', ['minimum', 'maximum']);
|
||||||
|
|
||||||
|
mockGraph.minimum.andReturn(12.3412121);
|
||||||
|
mockGraph.maximum.andReturn(88.7555555);
|
||||||
|
mockGraph.key = "def";
|
||||||
|
|
||||||
|
expect(controller.label(mockGraph)).toEqual({
|
||||||
|
title: "Test type (Test units)",
|
||||||
|
low: "12.341",
|
||||||
|
middle: "50.548",
|
||||||
|
high: "88.756"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,31 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
[
|
||||||
|
'../../src/controllers/TimelineTableController',
|
||||||
|
'../../src/TimelineFormatter'
|
||||||
|
],
|
||||||
|
function (TimelineTableController, TimelineFormatter) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
describe("The timeline table controller", function () {
|
||||||
|
var formatter, controller;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
controller = new TimelineTableController();
|
||||||
|
formatter = new TimelineFormatter();
|
||||||
|
});
|
||||||
|
|
||||||
|
// This controller's job is just to expose the formatter
|
||||||
|
// in scope, so simply verify that the two agree.
|
||||||
|
it("formats durations", function () {
|
||||||
|
[ 0, 100, 4123, 93600, 748801230012].forEach(function (n) {
|
||||||
|
expect(controller.niceTime(n))
|
||||||
|
.toEqual(formatter.format(n));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,67 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/controllers/TimelineTickController', '../../src/TimelineFormatter'],
|
||||||
|
function (TimelineTickController, TimelineFormatter) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var BILLION = 1000000000,
|
||||||
|
FORMATTER = new TimelineFormatter();
|
||||||
|
|
||||||
|
describe("The timeline tick controller", function () {
|
||||||
|
var mockToMillis,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
function expectedTick(pixelValue) {
|
||||||
|
return {
|
||||||
|
left: pixelValue,
|
||||||
|
text: FORMATTER.format(pixelValue * 2 + BILLION)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockToMillis = jasmine.createSpy('toMillis');
|
||||||
|
mockToMillis.andCallFake(function (v) {
|
||||||
|
return v * 2 + BILLION;
|
||||||
|
});
|
||||||
|
controller = new TimelineTickController();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes tick marks within a requested pixel span", function () {
|
||||||
|
// Simple case
|
||||||
|
expect(controller.labels(8000, 300, 100, mockToMillis))
|
||||||
|
.toEqual([8000, 8100, 8200, 8300].map(expectedTick));
|
||||||
|
|
||||||
|
// Slightly more complicated case
|
||||||
|
expect(controller.labels(7480, 4500, 1000, mockToMillis))
|
||||||
|
.toEqual([7000, 8000, 9000, 10000, 11000, 12000].map(expectedTick));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not rebuild arrays for same inputs", function () {
|
||||||
|
var firstValue = controller.labels(800, 300, 100, mockToMillis);
|
||||||
|
|
||||||
|
expect(controller.labels(800, 300, 100, mockToMillis))
|
||||||
|
.toEqual(firstValue);
|
||||||
|
|
||||||
|
expect(controller.labels(800, 300, 100, mockToMillis))
|
||||||
|
.toBe(firstValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does rebuild arrays when zoom changes", function () {
|
||||||
|
var firstValue = controller.labels(800, 300, 100, mockToMillis);
|
||||||
|
|
||||||
|
mockToMillis.andCallFake(function (v) {
|
||||||
|
return BILLION * 2 + v;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(controller.labels(800, 300, 100, mockToMillis))
|
||||||
|
.not.toEqual(firstValue);
|
||||||
|
|
||||||
|
expect(controller.labels(800, 300, 100, mockToMillis))
|
||||||
|
.not.toBe(firstValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,80 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../src/controllers/TimelineZoomController'],
|
||||||
|
function (TimelineZoomController) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("The timeline zoom state controller", function () {
|
||||||
|
var testConfiguration,
|
||||||
|
mockScope,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
testConfiguration = {
|
||||||
|
levels: [
|
||||||
|
1000,
|
||||||
|
2000,
|
||||||
|
3500
|
||||||
|
],
|
||||||
|
width: 12321
|
||||||
|
};
|
||||||
|
mockScope = jasmine.createSpyObj("$scope", ['$watch']);
|
||||||
|
mockScope.commit = jasmine.createSpy('commit');
|
||||||
|
controller = new TimelineZoomController(
|
||||||
|
mockScope,
|
||||||
|
testConfiguration
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts off at a middle zoom level", function () {
|
||||||
|
expect(controller.zoom()).toEqual(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows duration to be changed", function () {
|
||||||
|
var initial = controller.duration();
|
||||||
|
controller.duration(initial * 3.33);
|
||||||
|
expect(controller.duration() > initial).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles time-to-pixel conversions", function () {
|
||||||
|
var zoomLevel = controller.zoom();
|
||||||
|
expect(controller.toPixels(zoomLevel)).toEqual(12321);
|
||||||
|
expect(controller.toPixels(zoomLevel * 2)).toEqual(24642);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles pixel-to-time conversions", function () {
|
||||||
|
var zoomLevel = controller.zoom();
|
||||||
|
expect(controller.toMillis(12321)).toEqual(zoomLevel);
|
||||||
|
expect(controller.toMillis(24642)).toEqual(zoomLevel * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows zoom to be changed", function () {
|
||||||
|
controller.zoom(1);
|
||||||
|
expect(controller.zoom()).toEqual(3500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not normally persist zoom changes", function () {
|
||||||
|
controller.zoom(1);
|
||||||
|
expect(mockScope.commit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists zoom changes in Edit mode", function () {
|
||||||
|
mockScope.domainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
['hasCapability']
|
||||||
|
);
|
||||||
|
mockScope.domainObject.hasCapability.andCallFake(function (c) {
|
||||||
|
return c === 'editor';
|
||||||
|
});
|
||||||
|
controller.zoom(1);
|
||||||
|
expect(mockScope.commit).toHaveBeenCalled();
|
||||||
|
expect(mockScope.configuration.zoomLevel)
|
||||||
|
.toEqual(jasmine.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
["../../src/controllers/WARPDateTimeController"],
|
||||||
|
function (WARPDateTimeController) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
describe("The date-time controller for timeline creation", function () {
|
||||||
|
var mockScope,
|
||||||
|
controller;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockScope = jasmine.createSpyObj('$scope', ['$watchCollection']);
|
||||||
|
mockScope.field = 'testField';
|
||||||
|
mockScope.ngModel = { testField: { timestamp: 0, epoch: "SET" } };
|
||||||
|
controller = new WARPDateTimeController(mockScope);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Verify two-way binding support
|
||||||
|
it("updates model on changes to entry fields", function () {
|
||||||
|
// Make sure we're looking at the right watch
|
||||||
|
expect(mockScope.$watchCollection.calls[0].args[0])
|
||||||
|
.toEqual("datetime");
|
||||||
|
mockScope.$watchCollection.calls[0].args[1]({
|
||||||
|
days: 4,
|
||||||
|
hours: 12,
|
||||||
|
minutes: 30,
|
||||||
|
seconds: 11
|
||||||
|
});
|
||||||
|
expect(mockScope.ngModel.testField.timestamp).toEqual(
|
||||||
|
((((((4 * 24) + 12) * 60) + 30) * 60) + 11) * 1000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates form when model changes", function () {
|
||||||
|
// Make sure we're looking at the right watch
|
||||||
|
expect(mockScope.$watchCollection.calls[1].args[0])
|
||||||
|
.toEqual(jasmine.any(Function));
|
||||||
|
// ...and that it's really looking at the field in ngModel
|
||||||
|
expect(mockScope.$watchCollection.calls[1].args[0]())
|
||||||
|
.toBe(mockScope.ngModel.testField);
|
||||||
|
mockScope.$watchCollection.calls[1].args[1]({
|
||||||
|
timestamp: ((((((4 * 24) + 12) * 60) + 30) * 60) + 11) * 1000
|
||||||
|
});
|
||||||
|
expect(mockScope.datetime).toEqual({
|
||||||
|
days: 4,
|
||||||
|
hours: 12,
|
||||||
|
minutes: 30,
|
||||||
|
seconds: 11
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,66 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../../src/controllers/drag/TimelineDragHandleFactory'],
|
||||||
|
function (TimelineDragHandleFactory) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A Timeline drag handle factory", function () {
|
||||||
|
var mockDragHandler,
|
||||||
|
mockSnapHandler,
|
||||||
|
mockDomainObject,
|
||||||
|
mockType,
|
||||||
|
testType,
|
||||||
|
factory;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockDragHandler = jasmine.createSpyObj(
|
||||||
|
'dragHandler',
|
||||||
|
[ 'start' ]
|
||||||
|
);
|
||||||
|
mockSnapHandler = jasmine.createSpyObj(
|
||||||
|
'snapHandler',
|
||||||
|
[ 'snap' ]
|
||||||
|
);
|
||||||
|
mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject',
|
||||||
|
[ 'getCapability', 'getId' ]
|
||||||
|
);
|
||||||
|
mockType = jasmine.createSpyObj(
|
||||||
|
'type',
|
||||||
|
[ 'instanceOf' ]
|
||||||
|
);
|
||||||
|
|
||||||
|
mockDomainObject.getId.andReturn('test-id');
|
||||||
|
mockDomainObject.getCapability.andReturn(mockType);
|
||||||
|
mockType.instanceOf.andCallFake(function (t) {
|
||||||
|
return t === testType;
|
||||||
|
});
|
||||||
|
|
||||||
|
factory = new TimelineDragHandleFactory(
|
||||||
|
mockDragHandler,
|
||||||
|
mockSnapHandler
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inspects an object's type capability", function () {
|
||||||
|
factory.handles(mockDomainObject);
|
||||||
|
expect(mockDomainObject.getCapability)
|
||||||
|
.toHaveBeenCalledWith('type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides three handles for activities", function () {
|
||||||
|
testType = "warp.activity";
|
||||||
|
expect(factory.handles(mockDomainObject).length)
|
||||||
|
.toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides two handles for timelines", function () {
|
||||||
|
testType = "warp.timeline";
|
||||||
|
expect(factory.handles(mockDomainObject).length)
|
||||||
|
.toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
@ -0,0 +1,209 @@
|
|||||||
|
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
|
||||||
|
|
||||||
|
define(
|
||||||
|
['../../../src/controllers/drag/TimelineDragHandler'],
|
||||||
|
function (TimelineDragHandler) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
describe("A Timeline drag handler", function () {
|
||||||
|
var mockLoader,
|
||||||
|
mockSelection,
|
||||||
|
testConfiguration,
|
||||||
|
mockDomainObject,
|
||||||
|
mockDomainObjects,
|
||||||
|
mockTimespans,
|
||||||
|
mockMutations,
|
||||||
|
mockPersists,
|
||||||
|
mockCallback,
|
||||||
|
handler;
|
||||||
|
|
||||||
|
function asPromise(value) {
|
||||||
|
return (value || {}).then ? value : {
|
||||||
|
then: function (callback) {
|
||||||
|
return asPromise(callback(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subgraph(domainObject, objects) {
|
||||||
|
function lookupSubgraph(id) {
|
||||||
|
return subgraph(objects[id], objects);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
domainObject: domainObject,
|
||||||
|
composition: (domainObject.getModel().composition || [])
|
||||||
|
.map(lookupSubgraph)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockDomainObject(id, composition) {
|
||||||
|
var mockDomainObject = jasmine.createSpyObj(
|
||||||
|
'domainObject-' + id,
|
||||||
|
['getId', 'getModel', 'getCapability', 'useCapability']
|
||||||
|
);
|
||||||
|
|
||||||
|
mockDomainObject.getId.andReturn(id);
|
||||||
|
mockDomainObject.getModel.andReturn({ composition: composition });
|
||||||
|
mockDomainObject.useCapability.andReturn(asPromise(mockTimespans[id]));
|
||||||
|
mockDomainObject.getCapability.andCallFake(function (c) {
|
||||||
|
return {
|
||||||
|
persistence: mockPersists[id],
|
||||||
|
mutation: mockMutations[id]
|
||||||
|
}[c];
|
||||||
|
});
|
||||||
|
|
||||||
|
return mockDomainObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
mockTimespans = {};
|
||||||
|
mockPersists = {};
|
||||||
|
mockMutations = {};
|
||||||
|
['a', 'b', 'c', 'd', 'e', 'f'].forEach(function (id, index) {
|
||||||
|
mockTimespans[id] = jasmine.createSpyObj(
|
||||||
|
'timespan-' + id,
|
||||||
|
[ 'getStart', 'getEnd', 'getDuration', 'setStart', 'setEnd', 'setDuration' ]
|
||||||
|
);
|
||||||
|
mockPersists[id] = jasmine.createSpyObj(
|
||||||
|
'persistence-' + id,
|
||||||
|
[ 'persist' ]
|
||||||
|
);
|
||||||
|
mockMutations[id] = jasmine.createSpyObj(
|
||||||
|
'mutation-' + id,
|
||||||
|
[ 'mutate' ]
|
||||||
|
);
|
||||||
|
mockTimespans[id].getStart.andReturn(index * 1000);
|
||||||
|
mockTimespans[id].getDuration.andReturn(4000 + index);
|
||||||
|
mockTimespans[id].getEnd.andReturn(4000 + index + index * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
mockLoader = jasmine.createSpyObj('objectLoader', ['load']);
|
||||||
|
mockDomainObject = makeMockDomainObject('a', ['b', 'c']);
|
||||||
|
mockDomainObjects = {
|
||||||
|
a: mockDomainObject,
|
||||||
|
b: makeMockDomainObject('b', ['d']),
|
||||||
|
c: makeMockDomainObject('c', ['e', 'f']),
|
||||||
|
d: makeMockDomainObject('d', []),
|
||||||
|
e: makeMockDomainObject('e', []),
|
||||||
|
f: makeMockDomainObject('f', [])
|
||||||
|
};
|
||||||
|
mockSelection = jasmine.createSpyObj('selection', ['get', 'select']);
|
||||||
|
mockCallback = jasmine.createSpy('callback');
|
||||||
|
|
||||||
|
testConfiguration = {};
|
||||||
|
|
||||||
|
mockLoader.load.andReturn(asPromise(
|
||||||
|
subgraph(mockDomainObject, mockDomainObjects)
|
||||||
|
));
|
||||||
|
|
||||||
|
handler = new TimelineDragHandler(
|
||||||
|
mockDomainObject,
|
||||||
|
mockLoader
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the loader to find subgraph", function () {
|
||||||
|
expect(mockLoader.load).toHaveBeenCalledWith(
|
||||||
|
mockDomainObject,
|
||||||
|
'timespan'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports available object identifiers", function () {
|
||||||
|
expect(handler.ids())
|
||||||
|
.toEqual(Object.keys(mockDomainObjects).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes start/end/duration from timespan capabilities", function () {
|
||||||
|
expect(handler.start('a')).toEqual(0);
|
||||||
|
expect(handler.start('b')).toEqual(1000);
|
||||||
|
expect(handler.start('c')).toEqual(2000);
|
||||||
|
expect(handler.duration('a')).toEqual(4000);
|
||||||
|
expect(handler.duration('b')).toEqual(4001);
|
||||||
|
expect(handler.duration('c')).toEqual(4002);
|
||||||
|
expect(handler.end('a')).toEqual(4000);
|
||||||
|
expect(handler.end('b')).toEqual(5001);
|
||||||
|
expect(handler.end('c')).toEqual(6002);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts objects instead of identifiers for start/end/duration calls", function () {
|
||||||
|
Object.keys(mockDomainObjects).forEach(function (id) {
|
||||||
|
expect(handler.start(mockDomainObjects[id])).toEqual(handler.start(id));
|
||||||
|
expect(handler.duration(mockDomainObjects[id])).toEqual(handler.duration(id));
|
||||||
|
expect(handler.end(mockDomainObjects[id])).toEqual(handler.end(id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mutates objects", function () {
|
||||||
|
handler.start('a', 123);
|
||||||
|
expect(mockTimespans.a.setStart).toHaveBeenCalledWith(123);
|
||||||
|
handler.duration('b', 42);
|
||||||
|
expect(mockTimespans.b.setDuration).toHaveBeenCalledWith(42);
|
||||||
|
handler.end('c', 12321);
|
||||||
|
expect(mockTimespans.c.setEnd).toHaveBeenCalledWith(12321);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disallows negative starts, durations", function () {
|
||||||
|
handler.start('a', -100);
|
||||||
|
handler.duration('b', -1000);
|
||||||
|
expect(mockTimespans.a.setStart).toHaveBeenCalledWith(0);
|
||||||
|
expect(mockTimespans.b.setDuration).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disallows starts greater than ends violations", function () {
|
||||||
|
handler.start('a', 5000);
|
||||||
|
handler.end('b', 500);
|
||||||
|
expect(mockTimespans.a.setStart).toHaveBeenCalledWith(4000); // end time
|
||||||
|
expect(mockTimespans.b.setEnd).toHaveBeenCalledWith(1000); // start time
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves objects in groups", function () {
|
||||||
|
handler.move('b', 42);
|
||||||
|
expect(mockTimespans.b.setStart).toHaveBeenCalledWith(1042);
|
||||||
|
expect(mockTimespans.b.setEnd).toHaveBeenCalledWith(5043);
|
||||||
|
expect(mockTimespans.d.setStart).toHaveBeenCalledWith(3042);
|
||||||
|
expect(mockTimespans.d.setEnd).toHaveBeenCalledWith(7045);
|
||||||
|
// Verify no other interactions
|
||||||
|
['a', 'c', 'e', 'f'].forEach(function (id) {
|
||||||
|
expect(mockTimespans[id].setStart).not.toHaveBeenCalled();
|
||||||
|
expect(mockTimespans[id].setEnd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves whole subtrees", function () {
|
||||||
|
handler.move('a', 12321);
|
||||||
|
// We verify the math in the previous test, so just verify
|
||||||
|
// that the whole tree is effected here.
|
||||||
|
Object.keys(mockTimespans).forEach(function (id) {
|
||||||
|
expect(mockTimespans[id].setStart).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents bulk moves past 0", function () {
|
||||||
|
// Have a start later; new lowest start is b, at 1000
|
||||||
|
mockTimespans.a.getStart.andReturn(10000);
|
||||||
|
handler.move('a', -10000);
|
||||||
|
// Verify that move was stopped at 0, for b, even though
|
||||||
|
// move was initiated at a
|
||||||
|
expect(mockTimespans.a.setStart).toHaveBeenCalledWith(9000);
|
||||||
|
expect(mockTimespans.b.setStart).toHaveBeenCalledWith(0);
|
||||||
|
expect(mockTimespans.c.setStart).toHaveBeenCalledWith(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists mutated objects", function () {
|
||||||
|
handler.start('a', 20);
|
||||||
|
handler.end('b', 50);
|
||||||
|
handler.duration('c', 30);
|
||||||
|
handler.persist();
|
||||||
|
expect(mockPersists.a.persist).toHaveBeenCalled();
|
||||||
|
expect(mockPersists.b.persist).toHaveBeenCalled();
|
||||||
|
expect(mockPersists.c.persist).toHaveBeenCalled();
|
||||||
|
expect(mockPersists.d.persist).not.toHaveBeenCalled();
|
||||||
|
expect(mockPersists.e.persist).not.toHaveBeenCalled();
|
||||||
|
expect(mockPersists.f.persist).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user