[Plugins] Bring over timeline, clock plugins

WTD-1239
This commit is contained in:
Victor Woeltjen 2015-09-14 16:45:38 -07:00
parent 8c1b70f085
commit c932e953bc
119 changed files with 10485 additions and 0 deletions

View File

@ -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",

View 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"
}
]
}
}

View 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);

View 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>

View 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>

View File

@ -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;
}
);

View 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;
}
);

View 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;
}
);

View 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;
}
);

View File

@ -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;
}
);

View 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;
}
);

View 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;
}
);

View 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;
}
);

View 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;
}
);

View File

@ -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();
});
});
}
);

View File

@ -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();
});
});
}
);

View 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();
});
});
}
);

View File

@ -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();
});
});
}
);

View File

@ -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();
});
});
}
);

View 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);
});
});
}
);

View File

@ -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");
});
});
}
);

View File

@ -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));
});
});
}
);

View 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);
});
});
}
);

View File

@ -0,0 +1,11 @@
[
"actions/AbstractStartTimerAction",
"actions/RestartTimerAction",
"actions/StartTimerAction",
"controllers/ClockController",
"controllers/RefreshingController",
"controllers/TimerController",
"controllers/TimerFormatter",
"indicators/ClockIndicator",
"services/TickerService"
]

View 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)
}
}
```

View 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": "%"
}
]
}
}

View 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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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()">
&#x00e9;
</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()}}"
>
&#x00e8;
</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>

View 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>

View 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">&#x00e9;</span>
<span class="l-col l-col-icon l-col-link ui-symbol">&#x00e8;</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>

View 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>

View 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
});

View 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;
}
);

View 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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View 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;
}
);

View File

@ -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;
}
);

View 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;
}
);

View 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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View 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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

@ -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'
});

View File

@ -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;
}
);

View 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;
}
);

View 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;
}
);

View 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));
});
});
}
);

View 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");
});
});
}
);

View File

@ -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)
});
});
});
}
);

View File

@ -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);
});
});
}
);

View File

@ -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));
});
});
}
);

View File

@ -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();
});
});
}
);

View File

@ -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);
});
});
}
);

View File

@ -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)
});
});
});
}
);

View File

@ -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);
});
});
}
);

View File

@ -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();
});
});
}
);

View File

@ -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);
});
});
}
);

View File

@ -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));
});
});
}
);

View File

@ -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']);
});
});
}
);

View File

@ -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();
});
});
}
);

View File

@ -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();
});
});
}
);

View File

@ -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
});
});
}
);

View File

@ -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"
});
});
});
}
);

View File

@ -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));
});
});
});
}
);

View File

@ -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);
});
});
}
);

View File

@ -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));
});
});
}
);

View File

@ -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
});
});
});
}
);

View File

@ -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);
});
});
}
);

View File

@ -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