[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

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

View File

@@ -0,0 +1,53 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/drag/TimelineDragPopulator'],
function (TimelineDragPopulator) {
"use strict";
describe("The timeline drag populator", function () {
var mockObjectLoader,
mockPromise,
mockSwimlane,
mockDomainObject,
populator;
beforeEach(function () {
mockObjectLoader = jasmine.createSpyObj("objectLoader", ["load"]);
mockPromise = jasmine.createSpyObj("promise", ["then"]);
mockSwimlane = jasmine.createSpyObj("swimlane", ["color"]);
mockDomainObject = jasmine.createSpyObj(
"domainObject",
["getCapability", "getId"]
);
mockSwimlane.domainObject = mockDomainObject;
mockObjectLoader.load.andReturn(mockPromise);
populator = new TimelineDragPopulator(mockObjectLoader);
});
it("loads timespans for the represented object's subgraph", function () {
populator.populate(mockDomainObject);
expect(mockObjectLoader.load).toHaveBeenCalledWith(
mockDomainObject,
'timespan'
);
});
it("updates handles for selections", function () {
// Ensure we have a represented object context
populator.populate(mockDomainObject);
// Initially, no selection and no handles
expect(populator.get()).toEqual([]);
// Select the swimlane
populator.select(mockSwimlane);
// We should have handles now
expect(populator.get().length).toEqual(3);
});
});
}
);

View File

@@ -0,0 +1,96 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/drag/TimelineEndHandle', '../../../src/TimelineConstants'],
function (TimelineEndHandle, TimelineConstants) {
'use strict';
describe("A Timeline end drag handle", function () {
var mockDragHandler,
mockSnapHandler,
mockZoomController,
handle;
beforeEach(function () {
mockDragHandler = jasmine.createSpyObj(
'dragHandler',
[ 'end', 'persist' ]
);
mockSnapHandler = jasmine.createSpyObj(
'snapHandler',
[ 'snap' ]
);
mockZoomController = jasmine.createSpyObj(
'zoom',
[ 'toMillis', 'toPixels' ]
);
mockDragHandler.end.andReturn(12321);
// Echo back the value from snapper for most tests
mockSnapHandler.snap.andCallFake(function (ts) {
return ts;
});
// Double pixels to get millis, for test purposes
mockZoomController.toMillis.andCallFake(function (px) {
return px * 2;
});
mockZoomController.toPixels.andCallFake(function (ms) {
return ms / 2;
});
handle = new TimelineEndHandle(
'test-id',
mockDragHandler,
mockSnapHandler
);
});
it("provides a style for templates", function () {
var w = TimelineConstants.HANDLE_WIDTH;
expect(handle.style(mockZoomController)).toEqual({
// Left should be adjusted by zoom controller
left: (12321 / 2) - w + 'px',
// Width should match the defined constant
width: w + 'px'
});
});
it("forwards drags to the drag handler", function () {
handle.begin();
handle.drag(100, mockZoomController);
// Should have been interpreted as a +200 ms change
expect(mockDragHandler.end).toHaveBeenCalledWith(
"test-id",
12521
);
});
it("snaps drags to other end points", function () {
mockSnapHandler.snap.andReturn(42);
handle.begin();
handle.drag(-10, mockZoomController);
// Should have used snap-to timestamp
expect(mockDragHandler.end).toHaveBeenCalledWith(
"test-id",
42
);
});
it("persists when a move is complete", function () {
// Simulate normal drag cycle
handle.begin();
handle.drag(100, mockZoomController);
// Should not have persisted yet
expect(mockDragHandler.persist).not.toHaveBeenCalled();
// Finish the drag
handle.finish();
// Now it should have persisted
expect(mockDragHandler.persist).toHaveBeenCalled();
});
});
}
);

View File

@@ -0,0 +1,163 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/drag/TimelineMoveHandle', '../../../src/TimelineConstants'],
function (TimelineMoveHandle, TimelineConstants) {
'use strict';
describe("A Timeline move drag handle", function () {
var mockDragHandler,
mockSnapHandler,
mockZoomController,
handle;
beforeEach(function () {
mockDragHandler = jasmine.createSpyObj(
'dragHandler',
[ 'start', 'duration', 'end', 'move', 'persist' ]
);
mockSnapHandler = jasmine.createSpyObj(
'snapHandler',
[ 'snap' ]
);
mockZoomController = jasmine.createSpyObj(
'zoom',
[ 'toMillis', 'toPixels' ]
);
mockDragHandler.start.andReturn(12321);
mockDragHandler.duration.andReturn(4200);
mockDragHandler.end.andReturn(12321 + 4200);
// Echo back the value from snapper for most tests
mockSnapHandler.snap.andCallFake(function (ts) {
return ts;
});
// Double pixels to get millis, for test purposes
mockZoomController.toMillis.andCallFake(function (px) {
return px * 2;
});
mockZoomController.toPixels.andCallFake(function (ms) {
return ms / 2;
});
handle = new TimelineMoveHandle(
'test-id',
mockDragHandler,
mockSnapHandler
);
});
it("provides a style for templates", function () {
var w = TimelineConstants.HANDLE_WIDTH;
expect(handle.style(mockZoomController)).toEqual({
// Left should be adjusted by zoom controller
left: (12321 / 2) + w + 'px',
// Width should be duration minus end points
width: 2100 - (w * 2) + 'px'
});
});
it("forwards drags to the drag handler", function () {
handle.begin();
handle.drag(100, mockZoomController);
// Should have been interpreted as a +200 ms change
expect(mockDragHandler.move).toHaveBeenCalledWith(
"test-id",
200
);
});
it("tracks drags incrementally", function () {
handle.begin();
handle.drag(100, mockZoomController);
// Should have been interpreted as a +200 ms change...
expect(mockDragHandler.move).toHaveBeenCalledWith(
"test-id",
200
);
// Reflect the change from the drag handler
mockDragHandler.start.andReturn(12521);
mockDragHandler.end.andReturn(12521 + 4200);
// ....followed by a +100 ms change.
handle.drag(150, mockZoomController);
expect(mockDragHandler.move).toHaveBeenCalledWith(
"test-id",
100
);
});
it("snaps drags to other end points", function () {
mockSnapHandler.snap.andCallFake(function (ts) {
return ts + 10;
});
handle.begin();
handle.drag(100, mockZoomController);
// Should have used snap-to timestamp, which was 10
// ms greater than the provided one
expect(mockDragHandler.move).toHaveBeenCalledWith(
"test-id",
210
);
});
it("considers snaps for both endpoints", function () {
handle.begin();
expect(mockSnapHandler.snap).not.toHaveBeenCalled();
handle.drag(100, mockZoomController);
expect(mockSnapHandler.snap.calls.length).toEqual(2);
});
it("chooses the closest snap-to location", function () {
// Use a toggle to give snapped timestamps that are
// different distances away from the original.
// The move handle needs to choose the closest snap-to,
// regardless of whether it is the start/end (which
// will vary based on the initial state of this toggle.)
var toggle = false;
mockSnapHandler.snap.andCallFake(function (ts) {
toggle = !toggle;
return ts + (toggle ? -5 : 10);
});
handle.begin();
handle.drag(100, mockZoomController);
expect(mockDragHandler.move).toHaveBeenCalledWith(
"test-id",
195 // Chose the -5
);
// Reflect the change from the drag handler
mockDragHandler.start.andReturn(12521 - 5);
mockDragHandler.end.andReturn(12521 + 4200 - 5);
toggle = true; // Change going-in state
handle.drag(300, mockZoomController);
// Note that the -5 offset is shown in the current state,
// so snapping to the -5 implies that the full 400ms will
// be moved (again, relative to dragHandler's reported state)
expect(mockDragHandler.move).toHaveBeenCalledWith(
"test-id",
400 // Still chose the -5
);
});
it("persists when a move is complete", function () {
// Simulate normal drag cycle
handle.begin();
handle.drag(100, mockZoomController);
// Should not have persisted yet
expect(mockDragHandler.persist).not.toHaveBeenCalled();
// Finish the drag
handle.finish();
// Now it should have persisted
expect(mockDragHandler.persist).toHaveBeenCalled();
});
});
}
);

View File

@@ -0,0 +1,60 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/drag/TimelineSnapHandler'],
function (TimelineSnapHandler) {
'use strict';
describe("A Timeline snap handler", function () {
var mockDragHandler,
handler;
beforeEach(function () {
var starts = { a: 1000, b: 2000, c: 2500, d: 2600 },
ends = { a: 2050, b: 3000, c: 2700, d: 10000 };
mockDragHandler = jasmine.createSpyObj(
'dragHandler',
[ 'start', 'end', 'ids' ]
);
mockDragHandler.ids.andReturn(['a', 'b', 'c', 'd']);
mockDragHandler.start.andCallFake(function (id) {
return starts[id];
});
mockDragHandler.end.andCallFake(function (id) {
return ends[id];
});
handler = new TimelineSnapHandler(mockDragHandler);
});
it("provides a preferred snap location within tolerance", function () {
expect(handler.snap(2511, 15, 'a')).toEqual(2500); // c's start
expect(handler.snap(2488, 15, 'a')).toEqual(2500); // c's start
expect(handler.snap(10, 1000, 'b')).toEqual(1000); // a's start
expect(handler.snap(2711, 20, 'd')).toEqual(2700); // c's end
});
it("excludes provided id from snapping", function () {
// Don't want objects to snap to themselves, so we need
// this exclusion.
expect(handler.snap(2010, 50, 'b')).toEqual(2050); // a's end
// Verify that b's start would have been used had the
// id not been provided
expect(handler.snap(2010, 50, 'd')).toEqual(2000);
});
it("snaps to the closest point, when multiple match", function () {
// 2600 and 2700 (plus others) are both in range here
expect(handler.snap(2651, 1000, 'a')).toEqual(2700);
});
it("does not snap if no points are within tolerance", function () {
// Closest are 1000 and 2000, which are well outside of tolerance
expect(handler.snap(1503, 100, 'd')).toEqual(1503);
});
});
}
);

View File

@@ -0,0 +1,95 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/drag/TimelineStartHandle', '../../../src/TimelineConstants'],
function (TimelineStartHandle, TimelineConstants) {
'use strict';
describe("A Timeline start drag handle", function () {
var mockDragHandler,
mockSnapHandler,
mockZoomController,
handle;
beforeEach(function () {
mockDragHandler = jasmine.createSpyObj(
'dragHandler',
[ 'start', 'persist' ]
);
mockSnapHandler = jasmine.createSpyObj(
'snapHandler',
[ 'snap' ]
);
mockZoomController = jasmine.createSpyObj(
'zoom',
[ 'toMillis', 'toPixels' ]
);
mockDragHandler.start.andReturn(12321);
// Echo back the value from snapper for most tests
mockSnapHandler.snap.andCallFake(function (ts) {
return ts;
});
// Double pixels to get millis, for test purposes
mockZoomController.toMillis.andCallFake(function (px) {
return px * 2;
});
mockZoomController.toPixels.andCallFake(function (ms) {
return ms / 2;
});
handle = new TimelineStartHandle(
'test-id',
mockDragHandler,
mockSnapHandler
);
});
it("provides a style for templates", function () {
expect(handle.style(mockZoomController)).toEqual({
// Left should be adjusted by zoom controller
left: (12321 / 2) + 'px',
// Width should match the defined constant
width: TimelineConstants.HANDLE_WIDTH + 'px'
});
});
it("forwards drags to the drag handler", function () {
handle.begin();
handle.drag(100, mockZoomController);
// Should have been interpreted as a +200 ms change
expect(mockDragHandler.start).toHaveBeenCalledWith(
"test-id",
12521
);
});
it("snaps drags to other end points", function () {
mockSnapHandler.snap.andReturn(42);
handle.begin();
handle.drag(-10, mockZoomController);
// Should have used snap-to timestamp
expect(mockDragHandler.start).toHaveBeenCalledWith(
"test-id",
42
);
});
it("persists when a move is complete", function () {
// Simulate normal drag cycle
handle.begin();
handle.drag(100, mockZoomController);
// Should not have persisted yet
expect(mockDragHandler.persist).not.toHaveBeenCalled();
// Finish the drag
handle.finish();
// Now it should have persisted
expect(mockDragHandler.persist).toHaveBeenCalled();
});
});
}
);

View File

@@ -0,0 +1,132 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/graph/TimelineGraphPopulator'],
function (TimelineGraphPopulator) {
'use strict';
describe("A Timeline's resource graph populator", function () {
var mockSwimlanes,
mockQ,
testResources,
populator;
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;
}));
}
beforeEach(function () {
testResources = {
a: [ 'xyz', 'abc' ],
b: [ 'xyz' ],
c: [ 'xyz', 'abc', 'def', 'ghi' ]
};
mockQ = jasmine.createSpyObj('$q', ['when', 'all']);
mockSwimlanes = ['a', 'b', 'c'].map(function (k) {
var mockSwimlane = jasmine.createSpyObj(
'swimlane-' + k,
[ 'graph', 'color' ]
),
mockGraph = jasmine.createSpyObj(
'graph-' + k,
[ 'getPointCount', 'getDomainValue', 'getRangeValue' ]
);
mockSwimlane.graph.andReturn(true);
mockSwimlane.domainObject = jasmine.createSpyObj(
'domainObject-' + k,
[ 'getCapability', 'hasCapability', 'useCapability', 'getId' ]
);
mockSwimlane.color.andReturn('#' + k);
// Provide just enough information about graphs to support
// determination of which graphs to show
mockSwimlane.domainObject.useCapability.andCallFake(function () {
var obj = {};
testResources[k].forEach(function (r) {
obj[r] = mockGraph;
});
return asPromise(obj);
});
mockSwimlane.domainObject.hasCapability
.andReturn(true);
mockSwimlane.domainObject.getId
.andReturn(k);
mockGraph.getPointCount.andReturn(0);
return mockSwimlane;
});
mockQ.when.andCallFake(asPromise);
mockQ.all.andCallFake(allPromises);
populator = new TimelineGraphPopulator(mockQ);
});
it("provides no graphs by default", function () {
expect(populator.get()).toEqual([]);
});
it("provides one graph per resource type", function () {
populator.populate(mockSwimlanes);
// There are 4 unique resource types shared here...
expect(populator.get().length).toEqual(4);
});
it("does not include graphs based on swimlane configuration", function () {
mockSwimlanes[2].graph.andReturn(false);
populator.populate(mockSwimlanes);
// Only two unique swimlanes in the other two
expect(populator.get().length).toEqual(2);
// Verify interactions as well
expect(mockSwimlanes[0].domainObject.useCapability)
.toHaveBeenCalledWith('graph');
expect(mockSwimlanes[1].domainObject.useCapability)
.toHaveBeenCalledWith('graph');
expect(mockSwimlanes[2].domainObject.useCapability)
.not.toHaveBeenCalled();
});
it("does not recreate graphs when swimlanes don't change", function () {
var initialValue;
// Get an initial set of graphs
populator.populate(mockSwimlanes);
initialValue = populator.get();
// Repopulate with same data; should get same graphs
populator.populate(mockSwimlanes);
expect(populator.get()).toBe(initialValue);
// Something changed...
mockSwimlanes.pop();
populator.populate(mockSwimlanes);
// Now we should get different graphs
expect(populator.get()).not.toBe(initialValue);
});
// Regression test for WTD-1155
it("does recreate graphs when inclusions change", function () {
var initialValue;
// Get an initial set of graphs
populator.populate(mockSwimlanes);
initialValue = populator.get();
// Change resource graph inclusion...
mockSwimlanes[1].graph.andReturn(false);
populator.populate(mockSwimlanes);
// Now we should get different graphs
expect(populator.get()).not.toBe(initialValue);
});
});
}
);

View File

@@ -0,0 +1,56 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/graph/TimelineGraphRenderer'],
function (TimelineGraphRenderer) {
'use strict';
describe("A Timeline's graph renderer", function () {
var renderer;
beforeEach(function () {
renderer = new TimelineGraphRenderer();
});
it("converts utilizations to buffers", function () {
var utilization = {
getPointCount: function () {
return 10;
},
getDomainValue: function (i) {
return i * 10;
},
getRangeValue: function (i) {
return Math.sin(i);
}
},
buffer = renderer.render(utilization),
i;
// Should be flat list of alternating x/y,
// so 20 elements
expect(buffer.length).toEqual(20);
// Verify contents
for (i = 0; i < 10; i += 1) {
// Check for 6 decimal digits of precision, roughly
// what we expect after conversion to 32-bit float
expect(buffer[i * 2]).toBeCloseTo(i * 10, 6);
expect(buffer[i * 2 + 1]).toBeCloseTo(Math.sin(i), 6);
}
});
it("decodes color strings", function () {
// Note that decoded color should have alpha channel as well
expect(renderer.decode('#FFFFFF'))
.toEqual([1, 1, 1, 1]);
expect(renderer.decode('#000000'))
.toEqual([0, 0, 0, 1]);
expect(renderer.decode('#FF8000'))
.toEqual([1, 0.5019607843137255, 0, 1]);
});
});
}
);

View File

@@ -0,0 +1,151 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/graph/TimelineGraph'],
function (TimelineGraph) {
'use strict';
describe("A Timeline's resource graph", function () {
var mockDomainObjects,
mockRenderer,
testColors,
mockGraphs,
graph;
function asPromise(v) {
return (v || {}).then ? v : {
then: function (callback) {
return asPromise(callback(v));
}
};
}
beforeEach(function () {
testColors = {
a: [ 0, 1, 0 ],
b: [ 1, 0, 1 ],
c: [ 1, 0, 0 ]
};
mockGraphs = [];
mockDomainObjects = {};
['a', 'b', 'c'].forEach(function (k, i) {
var mockGraph = jasmine.createSpyObj(
'utilization-' + k,
[ 'getPointCount', 'getDomainValue', 'getRangeValue' ]
);
mockDomainObjects[k] = jasmine.createSpyObj(
'domainObject-' + k,
[ 'getCapability', 'useCapability' ]
);
mockDomainObjects[k].useCapability.andReturn(asPromise({
testResource: mockGraph
}));
mockGraph.getPointCount.andReturn(i + 2);
mockGraph.testField = k;
mockGraph.testIndex = i;
// Store to allow changes later
mockGraphs.push(mockGraph);
});
mockRenderer = jasmine.createSpyObj(
'renderer',
[ 'render', 'decode' ]
);
mockRenderer.render.andCallFake(function (utilization) {
var result = [];
while (result.length < (utilization.testIndex + 2) * 2) {
result.push(Math.floor(result.length / 2));
// Alternate +/- to give something to test to
result.push(
((result.length % 4 > 1) ? -1 : 1) *
(10 * utilization.testIndex)
);
}
return result;
});
mockRenderer.decode.andCallFake(function (color) {
return testColors[color];
});
graph = new TimelineGraph(
'testResource',
mockDomainObjects,
mockRenderer
);
});
it("exposes minimum/maximum", function () {
expect(graph.minimum()).toEqual(-20);
expect(graph.maximum()).toEqual(20);
});
it("exposes resource key", function () {
expect(graph.key).toEqual('testResource');
});
it("exposes a rendered drawing object", function () {
// Much of the work here is done by the renderer,
// so just check that it got used and assigned
expect(graph.drawingObject.lines).toEqual([
{
color: testColors.a,
buffer: [0, 0, 1, 0],
points: 2
},
{
color: testColors.b,
buffer: [0, 10, 1, -10, 2, 10],
points: 3
},
{
color: testColors.c,
buffer: [0, 20, 1, -20, 2, 20, 3, -20],
points: 4
}
]);
});
it("allows its bounds to be specified", function () {
graph.setBounds(42, 12321);
expect(graph.drawingObject.origin[0]).toEqual(42);
expect(graph.drawingObject.dimensions[0]).toEqual(12321);
});
it("provides a minimum/maximum even with no data", function () {
mockGraphs.forEach(function (mockGraph) {
mockGraph.getPointCount.andReturn(0);
});
// Create a graph of these utilizations
graph = new TimelineGraph(
'testResource',
mockDomainObjects,
mockRenderer
);
// Verify that we get some usable defaults
expect(graph.minimum()).toEqual(jasmine.any(Number));
expect(graph.maximum()).toEqual(jasmine.any(Number));
expect(graph.maximum() > graph.minimum()).toBeTruthy();
});
it("refreshes lines upon request", function () {
// Mock renderer looks at testIndex, so change it here...
mockGraphs[0].testIndex = 3;
// Should trigger a new render
graph.refresh();
// Values correspond to the new index now
expect(graph.drawingObject.lines[0].buffer).toEqual(
[0, 30, 1, -30, 2, 30, 3, -30, 4, 30]
);
});
});
}
);

View File

@@ -0,0 +1,65 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/swimlane/TimelineColorAssigner'],
function (TimelineColorAssigner) {
'use strict';
describe("The Timeline legend color assigner", function () {
var testConfiguration,
assigner;
beforeEach(function () {
testConfiguration = {};
assigner = new TimelineColorAssigner(testConfiguration);
});
it("assigns colors by identifier", function () {
expect(assigner.get('xyz')).toBeUndefined();
assigner.assign('xyz');
expect(assigner.get('xyz')).toEqual(jasmine.any(String));
});
it("releases colors", function () {
assigner.assign('xyz');
assigner.release('xyz');
expect(assigner.get('xyz')).toBeUndefined();
});
it("provides at least 30 unique colors", function () {
var colors = {}, i, ids = [];
// Add item to set
function set(c) { colors[c] = true; }
// Generate ids
for (i = 0; i < 30; i += 1) { ids.push("id" + i); }
// Assign colors to each id, then retrieve colors,
// storing into the set
ids.forEach(assigner.assign);
ids.map(assigner.get).map(set);
// Should now be 30 elements in that set
expect(Object.keys(colors).length).toEqual(30);
});
it("populates the configuration with colors", function () {
assigner.assign('xyz');
expect(testConfiguration.xyz).toBeDefined();
});
it("preserves other colors when releases occur", function () {
var c;
assigner.assign('xyz');
c = assigner.get('xyz');
// Assign/release a different color
assigner.assign('abc');
assigner.release('abc');
// Our original assignment should be preserved
expect(assigner.get('xyz')).toEqual(c);
});
});
}
);

View File

@@ -0,0 +1,87 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/swimlane/TimelineProxy'],
function (TimelineProxy) {
'use strict';
describe("The Timeline's selection proxy", function () {
var mockDomainObject,
mockSelection,
mockActionCapability,
mockActions,
proxy;
beforeEach(function () {
mockDomainObject = jasmine.createSpyObj(
'domainObject',
['getCapability']
);
mockSelection = jasmine.createSpyObj(
'selection',
[ 'get' ]
);
mockActionCapability = jasmine.createSpyObj(
'action',
[ 'getActions' ]
);
mockActions = ['a', 'b', 'c'].map(function (type) {
var mockAction = jasmine.createSpyObj(
'action-' + type,
[ 'perform', 'getMetadata' ]
);
mockAction.getMetadata.andReturn({ type: type });
return mockAction;
});
mockDomainObject.getCapability.andReturn(mockActionCapability);
mockActionCapability.getActions.andReturn(mockActions);
proxy = new TimelineProxy(mockDomainObject, mockSelection);
});
it("triggers a create action on add", function () {
// Should trigger b's create action
proxy.add('b');
expect(mockActions[1].perform).toHaveBeenCalled();
// Also check that other actions weren't invoked
expect(mockActions[0].perform).not.toHaveBeenCalled();
expect(mockActions[2].perform).not.toHaveBeenCalled();
// Verify that interactions were for correct keys
expect(mockDomainObject.getCapability)
.toHaveBeenCalledWith('action');
expect(mockActionCapability.getActions)
.toHaveBeenCalledWith('create');
});
it("invokes the action on the selection, if any", function () {
var mockOtherObject = jasmine.createSpyObj(
'other',
['getCapability']
),
mockOtherAction = jasmine.createSpyObj(
'actionCapability',
['getActions']
),
mockAction = jasmine.createSpyObj(
'action',
['perform', 'getMetadata']
);
// Set up mocks
mockSelection.get.andReturn({ domainObject: mockOtherObject });
mockOtherObject.getCapability.andReturn(mockOtherAction);
mockOtherAction.getActions.andReturn([mockAction]);
mockAction.getMetadata.andReturn({ type: 'z' });
// Invoke add method; should create with selected object
proxy.add('z');
expect(mockAction.perform).toHaveBeenCalled();
});
});
}
);

View File

@@ -0,0 +1,160 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/swimlane/TimelineSwimlaneDecorator'],
function (TimelineSwimlaneDecorator) {
'use strict';
describe("A Timeline swimlane decorator", function () {
var mockSwimlane,
mockSelection,
mockCapabilities,
testModel,
mockPromise,
decorator;
beforeEach(function () {
mockSwimlane = {};
mockCapabilities = {};
testModel = {};
mockSelection = jasmine.createSpyObj('selection', ['select', 'get']);
mockSwimlane.domainObject = jasmine.createSpyObj(
'domainObject',
[ 'getCapability', 'getModel' ]
);
mockCapabilities.mutation = jasmine.createSpyObj(
'mutation',
['mutate']
);
mockCapabilities.persistence = jasmine.createSpyObj(
'persistence',
['persist']
);
mockCapabilities.type = jasmine.createSpyObj(
'type',
['instanceOf']
);
mockPromise = jasmine.createSpyObj('promise', ['then']);
mockSwimlane.domainObject.getCapability.andCallFake(function (c) {
return mockCapabilities[c];
});
mockSwimlane.domainObject.getModel.andReturn(testModel);
mockCapabilities.type.instanceOf.andReturn(true);
mockCapabilities.mutation.mutate.andReturn(mockPromise);
decorator = new TimelineSwimlaneDecorator(
mockSwimlane,
mockSelection
);
});
it("returns the same object instance", function () {
// Decoration should occur in-place
expect(decorator).toBe(mockSwimlane);
});
it("adds a 'modes' getter-setter to activities", function () {
expect(mockSwimlane.modes).toEqual(jasmine.any(Function));
expect(mockCapabilities.type.instanceOf)
.toHaveBeenCalledWith('warp.activity');
});
it("adds a 'link' getter-setter to activities", function () {
expect(mockSwimlane.link).toEqual(jasmine.any(Function));
expect(mockCapabilities.type.instanceOf)
.toHaveBeenCalledWith('warp.activity');
});
it("gets modes from the domain object model", function () {
testModel.relationships = { modes: ['a', 'b', 'c'] };
expect(decorator.modes()).toEqual(['a', 'b', 'c']);
testModel.relationships = { modes: ['x', 'y', 'z'] };
expect(decorator.modes()).toEqual(['x', 'y', 'z']);
// Verify that it worked as a getter
expect(mockCapabilities.mutation.mutate)
.not.toHaveBeenCalled();
});
it("gets links from the domain object model", function () {
testModel.link = "http://www.nasa.gov";
expect(decorator.link()).toEqual("http://www.nasa.gov");
// Verify that it worked as a getter
expect(mockCapabilities.mutation.mutate)
.not.toHaveBeenCalled();
});
it("mutates modes when used as a setter", function () {
decorator.modes(['abc', 'xyz']);
expect(mockCapabilities.mutation.mutate)
.toHaveBeenCalledWith(jasmine.any(Function));
mockCapabilities.mutation.mutate.mostRecentCall.args[0](testModel);
expect(testModel.relationships.modes).toEqual(['abc', 'xyz']);
// Verify that persistence is called when promise resolves
expect(mockCapabilities.persistence.persist).not.toHaveBeenCalled();
mockPromise.then.mostRecentCall.args[0]();
expect(mockCapabilities.persistence.persist).toHaveBeenCalled();
});
it("mutates modes when used as a setter", function () {
decorator.link("http://www.noaa.gov");
expect(mockCapabilities.mutation.mutate)
.toHaveBeenCalledWith(jasmine.any(Function));
mockCapabilities.mutation.mutate.mostRecentCall.args[0](testModel);
expect(testModel.link).toEqual("http://www.noaa.gov");
// Verify that persistence is called when promise resolves
expect(mockCapabilities.persistence.persist).not.toHaveBeenCalled();
mockPromise.then.mostRecentCall.args[0]();
expect(mockCapabilities.persistence.persist).toHaveBeenCalled();
});
it("does not provide a 'remove' method with no parent", function () {
expect(decorator.remove).not.toEqual(jasmine.any(Function));
});
it("fires the 'remove' action when remove is called", function () {
var mockChild = jasmine.createSpyObj(
'childObject',
[ 'getCapability', 'getModel' ]
),
mockAction = jasmine.createSpyObj(
'action',
[ 'perform' ]
);
mockChild.getCapability.andCallFake(function (c) {
return c === 'action' ? mockAction : undefined;
});
// Create a child swimlane; it should have a remove action
new TimelineSwimlaneDecorator({
domainObject: mockChild,
parent: decorator,
depth: 1
}).remove();
expect(mockChild.getCapability).toHaveBeenCalledWith('action');
expect(mockAction.perform).toHaveBeenCalledWith('remove');
});
it("allows the swimlane to be selected", function () {
decorator.select();
expect(mockSelection.select).toHaveBeenCalledWith(decorator);
});
it("allows checking for swimlane selection state", function () {
expect(decorator.selected()).toBeFalsy();
mockSelection.get.andReturn(decorator);
expect(decorator.selected()).toBeTruthy();
});
});
}
);

View File

@@ -0,0 +1,173 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/swimlane/TimelineSwimlaneDropHandler'],
function (TimelineSwimlaneDropHandler) {
"use strict";
describe("A timeline's swimlane drop handler", function () {
var mockSwimlane,
mockOtherObject,
mockActionCapability,
mockPersistence,
handler;
beforeEach(function () {
mockSwimlane = jasmine.createSpyObj(
"swimlane",
[ "highlight", "highlightBottom" ]
);
// domainObject, idPath, children, expanded
mockSwimlane.domainObject = jasmine.createSpyObj(
"domainObject",
[ "getId", "getCapability", "useCapability", "hasCapability" ]
);
mockSwimlane.idPath = [ 'a', 'b' ];
mockSwimlane.children = [ {} ];
mockSwimlane.expanded = true;
mockSwimlane.parent = {};
mockSwimlane.parent.idPath = ['a'];
mockSwimlane.parent.domainObject = jasmine.createSpyObj(
"domainObject",
[ "getId", "getCapability", "useCapability", "hasCapability" ]
);
mockSwimlane.parent.children = [ mockSwimlane ];
mockSwimlane.children[0].domainObject = jasmine.createSpyObj(
"domainObject",
[ "getId", "getCapability", "useCapability", "hasCapability" ]
);
mockOtherObject = jasmine.createSpyObj(
"domainObject",
[ "getId", "getCapability", "useCapability", "hasCapability" ]
);
mockActionCapability = jasmine.createSpyObj("action", ["perform", "getActions"]);
mockPersistence = jasmine.createSpyObj("persistence", ["persist"]);
mockActionCapability.getActions.andReturn([{}]);
mockSwimlane.parent.domainObject.getId.andReturn('a');
mockSwimlane.domainObject.getId.andReturn('b');
mockSwimlane.children[0].domainObject.getId.andReturn('c');
mockOtherObject.getId.andReturn('d');
mockSwimlane.domainObject.getCapability.andCallFake(function (c) {
return {
action: mockActionCapability,
persistence: mockPersistence
}[c];
});
mockOtherObject.getCapability.andReturn(mockActionCapability);
mockSwimlane.domainObject.hasCapability.andReturn(true);
handler = new TimelineSwimlaneDropHandler(mockSwimlane);
});
it("disallows drop outside of edit mode", function () {
// Verify precondition
expect(handler.allowDropIn('d')).toBeTruthy();
expect(handler.allowDropAfter('d')).toBeTruthy();
// Act as if we're not in edit mode
mockSwimlane.domainObject.hasCapability.andReturn(false);
// Now, they should be disallowed
expect(handler.allowDropIn('d')).toBeFalsy();
expect(handler.allowDropAfter('d')).toBeFalsy();
// Verify that editor capability was really checked for
expect(mockSwimlane.domainObject.hasCapability)
.toHaveBeenCalledWith('editor');
});
it("disallows dropping of parents", function () {
expect(handler.allowDropIn('a')).toBeFalsy();
expect(handler.allowDropAfter('a')).toBeFalsy();
});
it("does not drop when no highlight state is present", function () {
// If there's no hover highlight, there's no drop allowed
handler.drop('d', mockOtherObject);
expect(mockOtherObject.getCapability)
.not.toHaveBeenCalled();
expect(mockSwimlane.domainObject.useCapability)
.not.toHaveBeenCalled();
expect(mockSwimlane.parent.domainObject.useCapability)
.not.toHaveBeenCalled();
});
it("inserts into when highlighted", function () {
var testModel = { composition: [ 'c' ] };
mockSwimlane.highlight.andReturn(true);
handler.drop('d');
// Should have mutated
expect(mockSwimlane.domainObject.useCapability)
.toHaveBeenCalledWith("mutation", jasmine.any(Function));
// Run the mutator
mockSwimlane.domainObject.useCapability.mostRecentCall
.args[1](testModel);
expect(testModel.composition).toEqual(['c', 'd']);
// Finally, should also have persisted
expect(mockPersistence.persist).toHaveBeenCalled();
});
it("removes objects before insertion, if provided", function () {
var testModel = { composition: [ 'c' ] };
mockSwimlane.highlight.andReturn(true);
handler.drop('d', mockOtherObject);
// Should have invoked a remove action
expect(mockActionCapability.perform)
.toHaveBeenCalledWith('remove');
// Verify that mutator still ran as expected
mockSwimlane.domainObject.useCapability.mostRecentCall
.args[1](testModel);
expect(testModel.composition).toEqual(['c', 'd']);
});
it("inserts after as a peer when highlighted at the bottom", function () {
var testModel = { composition: [ 'x', 'b', 'y' ] };
mockSwimlane.highlightBottom.andReturn(true);
mockSwimlane.expanded = false;
handler.drop('d');
// Should have mutated
expect(mockSwimlane.parent.domainObject.useCapability)
.toHaveBeenCalledWith("mutation", jasmine.any(Function));
// Run the mutator
mockSwimlane.parent.domainObject.useCapability.mostRecentCall
.args[1](testModel);
expect(testModel.composition).toEqual([ 'x', 'b', 'd', 'y']);
});
it("inserts into when highlighted at the bottom and expanded", function () {
var testModel = { composition: [ 'c' ] };
mockSwimlane.highlightBottom.andReturn(true);
mockSwimlane.expanded = true;
handler.drop('d');
// Should have mutated
expect(mockSwimlane.domainObject.useCapability)
.toHaveBeenCalledWith("mutation", jasmine.any(Function));
// Run the mutator
mockSwimlane.domainObject.useCapability.mostRecentCall
.args[1](testModel);
expect(testModel.composition).toEqual([ 'd', 'c' ]);
});
it("inserts after as a peer when highlighted at the bottom and childless", function () {
var testModel = { composition: [ 'x', 'b', 'y' ] };
mockSwimlane.highlightBottom.andReturn(true);
mockSwimlane.expanded = true;
mockSwimlane.children = [];
handler.drop('d');
// Should have mutated
expect(mockSwimlane.parent.domainObject.useCapability)
.toHaveBeenCalledWith("mutation", jasmine.any(Function));
// Run the mutator
mockSwimlane.parent.domainObject.useCapability.mostRecentCall
.args[1](testModel);
expect(testModel.composition).toEqual([ 'x', 'b', 'd', 'y']);
});
});
}
);

View File

@@ -0,0 +1,135 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/swimlane/TimelineSwimlanePopulator'],
function (TimelineSwimlanePopulator) {
'use strict';
describe("A Timeline swimlane populator", function () {
var mockLoader,
mockSelection,
testConfiguration,
mockDomainObject,
mockDomainObjects,
mockCallback,
populator;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
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(false));
return mockDomainObject;
}
function subgraph(domainObject, objects) {
function lookupSubgraph(id) {
return subgraph(objects[id], objects);
}
return {
domainObject: domainObject,
composition: domainObject.getModel().composition
.map(lookupSubgraph)
};
}
beforeEach(function () {
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', 'proxy']
);
mockCallback = jasmine.createSpy('callback');
testConfiguration = {};
mockLoader.load.andReturn(asPromise(subgraph(
mockDomainObject,
mockDomainObjects
)));
populator = new TimelineSwimlanePopulator(
mockLoader,
testConfiguration,
mockSelection
);
});
it("uses the loader to find subgraph", function () {
populator.populate(mockDomainObject);
expect(mockLoader.load).toHaveBeenCalledWith(
mockDomainObject,
'timespan'
);
});
it("provides a list of swimlanes", function () {
populator.populate(mockDomainObject);
// Ensure swimlane order matches depth-first search
expect(populator.get().map(function (swimlane) {
return swimlane.domainObject;
})).toEqual([
mockDomainObjects.a,
mockDomainObjects.b,
mockDomainObjects.d,
mockDomainObjects.c,
mockDomainObjects.e,
mockDomainObjects.f
]);
});
it("clears swimlanes if no object is provided", function () {
populator.populate();
expect(populator.get()).toEqual([]);
});
it("preserves selection state when possible", function () {
// Repopulate swimlanes
populator.populate(mockDomainObject);
// Act as if something is already selected
mockSelection.get.andReturn(populator.get()[1]);
// Verify precondition
expect(mockSelection.select).not.toHaveBeenCalled();
// Repopulate swimlanes
populator.populate(mockDomainObject);
// Selection should have been preserved
expect(mockSelection.select).toHaveBeenCalled();
expect(mockSelection.select.mostRecentCall.args[0].domainObject)
.toEqual(mockDomainObjects.b);
});
it("exposes a selection proxy for the timeline", function () {
populator.populate(mockDomainObject);
expect(mockSelection.proxy).toHaveBeenCalled();
});
});
}
);

View File

@@ -0,0 +1,202 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../../src/controllers/swimlane/TimelineSwimlane'],
function (TimelineSwimlane) {
'use strict';
describe("A Timeline swimlane", function () {
var parent,
child,
mockParentObject,
mockChildObject,
mockAssigner,
mockActionCapability,
mockParentTimespan,
mockChildTimespan,
testConfiguration;
function asPromise(v) {
return { then: function (cb) { cb(v); } };
}
beforeEach(function () {
mockParentObject = jasmine.createSpyObj(
'parent',
['getId', 'getCapability', 'useCapability', 'getModel']
);
mockChildObject = jasmine.createSpyObj(
'child',
['getId', 'getCapability', 'useCapability', 'getModel']
);
mockAssigner = jasmine.createSpyObj(
'assigner',
['get', 'assign', 'release']
);
mockParentTimespan = jasmine.createSpyObj(
'parentTimespan',
['getStart', 'getEnd']
);
mockChildTimespan = jasmine.createSpyObj(
'childTimespan',
['getStart', 'getEnd']
);
mockActionCapability = jasmine.createSpyObj('action', ['perform']);
mockParentObject.getId.andReturn('test-parent');
mockParentObject.getCapability.andReturn(mockActionCapability);
mockParentObject.useCapability.andReturn(asPromise(mockParentTimespan));
mockParentObject.getModel.andReturn({ name: "Test Parent" });
mockChildObject.getModel.andReturn({ name: "Test Child" });
mockChildObject.useCapability.andReturn(asPromise(mockChildTimespan));
testConfiguration = { graph: {} };
parent = new TimelineSwimlane(
mockParentObject,
mockAssigner,
testConfiguration
);
child = new TimelineSwimlane(
mockChildObject,
mockAssigner,
testConfiguration,
parent
);
});
it("exposes its domain object", function () {
expect(parent.domainObject).toEqual(mockParentObject);
expect(child.domainObject).toEqual(mockChildObject);
});
it("exposes its depth", function () {
expect(parent.depth).toEqual(0);
expect(child.depth).toEqual(1);
expect(new TimelineSwimlane(mockParentObject, {}, {}, child).depth)
.toEqual(2);
});
it("exposes its path as readable text", function () {
var grandchild = new TimelineSwimlane(mockParentObject, {}, {}, child),
ggc = new TimelineSwimlane(mockParentObject, {}, {}, grandchild);
expect(parent.path).toEqual("");
expect(child.path).toEqual("");
expect(grandchild.path).toEqual("Test Child > ");
expect(ggc.path).toEqual("Test Child > Test Parent > ");
});
it("starts off expanded", function () {
expect(parent.expanded).toBeTruthy();
expect(child.expanded).toBeTruthy();
});
it("determines visibility based on parent expansion", function () {
parent.expanded = false;
expect(child.visible()).toBeFalsy();
parent.expanded = true;
expect(child.visible()).toBeTruthy();
});
it("is visible when it is the root of the timeline subgraph", function () {
expect(parent.visible()).toBeTruthy();
});
it("fires the Edit Properties action on request", function () {
parent.properties();
expect(mockParentObject.getCapability).toHaveBeenCalledWith('action');
expect(mockActionCapability.perform).toHaveBeenCalledWith('properties');
});
it("allows resource graph inclusion to be toggled", function () {
expect(testConfiguration.graph['test-parent']).toBeFalsy();
parent.toggleGraph();
expect(testConfiguration.graph['test-parent']).toBeTruthy();
parent.toggleGraph();
expect(testConfiguration.graph['test-parent']).toBeFalsy();
});
it("provides a getter-setter for graph inclusion", function () {
expect(testConfiguration.graph['test-parent']).toBeFalsy();
expect(parent.graph(true)).toBeTruthy();
expect(parent.graph()).toBeTruthy();
expect(testConfiguration.graph['test-parent']).toBeTruthy();
expect(parent.graph(false)).toBeFalsy();
expect(parent.graph()).toBeFalsy();
expect(testConfiguration.graph['test-parent']).toBeFalsy();
});
it("gets colors from the provided assigner", function () {
mockAssigner.get.andReturn("#ABCABC");
expect(parent.color()).toEqual("#ABCABC");
// Verify that id was passed, and no other interactions
expect(mockAssigner.get).toHaveBeenCalledWith('test-parent');
expect(mockAssigner.assign).not.toHaveBeenCalled();
expect(mockAssigner.release).not.toHaveBeenCalled();
});
it("allows colors to be set", function () {
parent.color("#F0000D");
expect(mockAssigner.assign).toHaveBeenCalledWith(
'test-parent',
"#F0000D"
);
});
it("assigns colors when resource graph state is toggled", function () {
expect(mockAssigner.assign).not.toHaveBeenCalled();
parent.toggleGraph();
expect(mockAssigner.assign).toHaveBeenCalledWith('test-parent');
expect(mockAssigner.release).not.toHaveBeenCalled();
parent.toggleGraph();
expect(mockAssigner.release).toHaveBeenCalledWith('test-parent');
});
it("assigns colors when resource graph state is set", function () {
expect(mockAssigner.assign).not.toHaveBeenCalled();
parent.graph(true);
expect(mockAssigner.assign).toHaveBeenCalledWith('test-parent');
expect(mockAssigner.release).not.toHaveBeenCalled();
parent.graph(false);
expect(mockAssigner.release).toHaveBeenCalledWith('test-parent');
});
it("provides getter-setters for drag-drop highlights", function () {
expect(parent.highlight()).toBeFalsy();
parent.highlight(true);
expect(parent.highlight()).toBeTruthy();
expect(parent.highlightBottom()).toBeFalsy();
parent.highlightBottom(true);
expect(parent.highlightBottom()).toBeTruthy();
});
it("detects start/end violations", function () {
mockParentTimespan.getStart.andReturn(42);
mockParentTimespan.getEnd.andReturn(12321);
// First, start with a valid timespan
mockChildTimespan.getStart.andReturn(84);
mockChildTimespan.getEnd.andReturn(100);
expect(child.exceeded()).toBeFalsy();
// Start time violation
mockChildTimespan.getStart.andReturn(21);
expect(child.exceeded()).toBeTruthy();
// Now both in violation
mockChildTimespan.getEnd.andReturn(20000);
expect(child.exceeded()).toBeTruthy();
// And just the end
mockChildTimespan.getStart.andReturn(100);
expect(child.exceeded()).toBeTruthy();
// Now back to everything's-just-fine
mockChildTimespan.getEnd.andReturn(10000);
expect(child.exceeded()).toBeFalsy();
});
});
}
);

View File

@@ -0,0 +1,15 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../src/directives/SwimlaneDragConstants'],
function (SwimlaneDragConstants) {
"use strict";
describe("Timeline swimlane drag constants", function () {
it("define a custom type for swimlane drag-drop", function () {
expect(SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE)
.toEqual(jasmine.any(String));
});
});
}
);

View File

@@ -0,0 +1,76 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../src/directives/WARPSwimlaneDrag', '../../src/directives/SwimlaneDragConstants'],
function (WARPSwimlaneDrag, SwimlaneDragConstants) {
"use strict";
describe("The warp-swimlane-drag directive", function () {
var mockDndService,
mockScope,
mockElement,
testAttrs,
handlers,
directive;
beforeEach(function () {
var scopeExprs = {
someTestExpr: "some swimlane"
};
handlers = {};
mockDndService = jasmine.createSpyObj(
'dndService',
['setData', 'getData', 'removeData']
);
mockScope = jasmine.createSpyObj('$scope', ['$eval']);
mockElement = jasmine.createSpyObj('element', ['on']);
testAttrs = { warpSwimlaneDrag: "someTestExpr" };
// Simulate evaluation of expressions in scope
mockScope.$eval.andCallFake(function (expr) {
return scopeExprs[expr];
});
directive = new WARPSwimlaneDrag(mockDndService);
// Run the link function, then capture the event handlers
// for testing.
directive.link(mockScope, mockElement, testAttrs);
mockElement.on.calls.forEach(function (call) {
handlers[call.args[0]] = call.args[1];
});
});
it("is available as an attribute", function () {
expect(directive.restrict).toEqual("A");
});
it("exposes the swimlane when dragging starts", function () {
// Verify precondition
expect(mockDndService.setData).not.toHaveBeenCalled();
// Start a drag
handlers.dragstart();
// Should have exposed the swimlane
expect(mockDndService.setData).toHaveBeenCalledWith(
SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE,
"some swimlane"
);
});
it("clears the swimlane when dragging ends", function () {
// Verify precondition
expect(mockDndService.removeData).not.toHaveBeenCalled();
// Start a drag
handlers.dragend();
// Should have exposed the swimlane
expect(mockDndService.removeData).toHaveBeenCalledWith(
SwimlaneDragConstants.WARP_SWIMLANE_DRAG_TYPE
);
});
});
}
);

View File

@@ -0,0 +1,147 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../src/directives/WARPSwimlaneDrop'],
function (WARPSwimlaneDrop) {
"use strict";
var TEST_HEIGHT = 100,
TEST_TOP = 600;
describe("The warp-swimlane-drop directive", function () {
var mockDndService,
mockScope,
mockElement,
testAttrs,
mockSwimlane,
mockRealElement,
testEvent,
handlers,
directive;
function getterSetter(value) {
return function (newValue) {
return (value = (arguments.length > 0) ? newValue : value);
};
}
beforeEach(function () {
var scopeExprs = {};
handlers = {};
mockDndService = jasmine.createSpyObj(
'dndService',
['setData', 'getData', 'removeData']
);
mockScope = jasmine.createSpyObj('$scope', ['$eval']);
mockElement = jasmine.createSpyObj('element', ['on']);
testAttrs = { warpSwimlaneDrop: "mockSwimlane" };
mockSwimlane = jasmine.createSpyObj(
"swimlane",
[ "allowDropIn", "allowDropAfter", "drop", "highlight", "highlightBottom" ]
);
mockElement[0] = jasmine.createSpyObj(
"realElement",
[ "getBoundingClientRect" ]
);
mockElement[0].offsetHeight = TEST_HEIGHT;
mockElement[0].getBoundingClientRect.andReturn({ top: TEST_TOP });
// Simulate evaluation of expressions in scope
scopeExprs.mockSwimlane = mockSwimlane;
mockScope.$eval.andCallFake(function (expr) {
return scopeExprs[expr];
});
mockSwimlane.allowDropIn.andReturn(true);
mockSwimlane.allowDropAfter.andReturn(true);
// Simulate getter-setter behavior
mockSwimlane.highlight.andCallFake(getterSetter(false));
mockSwimlane.highlightBottom.andCallFake(getterSetter(false));
testEvent = {
pageY: TEST_TOP + TEST_HEIGHT / 10,
dataTransfer: { getData: jasmine.createSpy() },
preventDefault: jasmine.createSpy()
};
testEvent.dataTransfer.getData.andReturn('abc');
mockDndService.getData.andReturn({ domainObject: 'someDomainObject' });
directive = new WARPSwimlaneDrop(mockDndService);
// Run the link function, then capture the event handlers
// for testing.
directive.link(mockScope, mockElement, testAttrs);
mockElement.on.calls.forEach(function (call) {
handlers[call.args[0]] = call.args[1];
});
});
it("is available as an attribute", function () {
expect(directive.restrict).toEqual("A");
});
it("updates highlights on drag over", function () {
// Near the top
testEvent.pageY = TEST_TOP + TEST_HEIGHT / 10;
handlers.dragover(testEvent);
expect(mockSwimlane.highlight).toHaveBeenCalledWith(true);
expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(false);
});
it("updates bottom highlights on drag over", function () {
// Near the bottom
testEvent.pageY = TEST_TOP + TEST_HEIGHT - TEST_HEIGHT / 10;
handlers.dragover(testEvent);
expect(mockSwimlane.highlight).toHaveBeenCalledWith(false);
expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(true);
});
it("respects swimlane's allowDropIn response", function () {
// Near the top
testEvent.pageY = TEST_TOP + TEST_HEIGHT / 10;
mockSwimlane.allowDropIn.andReturn(false);
handlers.dragover(testEvent);
expect(mockSwimlane.highlight).toHaveBeenCalledWith(false);
expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(false);
});
it("respects swimlane's allowDropAfter response", function () {
// Near the top
testEvent.pageY = TEST_TOP + TEST_HEIGHT - TEST_HEIGHT / 10;
mockSwimlane.allowDropAfter.andReturn(false);
handlers.dragover(testEvent);
expect(mockSwimlane.highlight).toHaveBeenCalledWith(false);
expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(false);
});
it("notifies swimlane on drop", function () {
handlers.drop(testEvent);
expect(mockSwimlane.drop).toHaveBeenCalledWith('abc', 'someDomainObject');
});
it("clears highlights when drag leaves", function () {
handlers.dragleave();
expect(mockSwimlane.highlight).toHaveBeenCalledWith(false);
expect(mockSwimlane.highlightBottom).toHaveBeenCalledWith(false);
});
});
}
);

View File

@@ -0,0 +1,136 @@
/*global define,describe,it,expect,beforeEach,waitsFor,jasmine,window,afterEach*/
define(
['../../src/services/ObjectLoader'],
function (ObjectLoader) {
"use strict";
describe("The domain object loader", function () {
var mockQ,
mockCallback,
mockDomainObjects,
testCompositions,
objectLoader;
function asPromise(value) {
return (value || {}).then ? value : {
then: function (callback) {
return asPromise(callback(value));
}
};
}
function lookupObject(id) {
return mockDomainObjects[id];
}
function fullSubgraph(id) {
return {
domainObject: mockDomainObjects[id],
composition: (testCompositions[id] || [])
.map(fullSubgraph)
};
}
function addDomainObject(id, children, capabilities) {
var mockDomainObject = jasmine.createSpyObj(
'object-' + id,
[ 'useCapability', 'hasCapability', 'getId' ]
);
mockDomainObject.getId.andReturn(id);
mockDomainObject.useCapability.andCallFake(function (c) {
return c === 'composition' ?
asPromise(children.map(lookupObject)) :
undefined;
});
mockDomainObject.hasCapability.andCallFake(function (c) {
return (capabilities.indexOf(c) !== -1) || (c === 'composition');
});
mockDomainObjects[id] = mockDomainObject;
testCompositions[id] = children;
}
beforeEach(function () {
mockQ = jasmine.createSpyObj('$q', [ 'when', 'all' ]);
mockCallback = jasmine.createSpy('callback');
mockDomainObjects = {};
testCompositions = {};
// Provide subset of q's actual behavior which we
// expect object loader to really need
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);
});
// Populate some mock domain objects
addDomainObject('a', ['b', 'c', 'd'], ['test']);
addDomainObject('b', ['c', 'd', 'ba'], []);
addDomainObject('c', ['ca'], ['test']);
addDomainObject('d', [], ['test']);
addDomainObject('ba', [], ['test']);
addDomainObject('ca', [], ['test']);
objectLoader = new ObjectLoader(mockQ);
});
it("loads sub-graphs of composition hierarchy", function () {
objectLoader.load(mockDomainObjects.a).then(mockCallback);
// Should have loaded full graph
expect(mockCallback).toHaveBeenCalledWith(fullSubgraph('a'));
});
it("filters based on capabilities, if requested", function () {
objectLoader.load(mockDomainObjects.a, 'test')
.then(mockCallback);
// Should have pruned 'b'
expect(mockCallback).toHaveBeenCalledWith({
domainObject: mockDomainObjects.a,
composition: [
fullSubgraph('c'),
fullSubgraph('d')
]
});
});
it("filters with a function, if requested", function () {
function shortName(domainObject) {
return domainObject.getId().length === 1;
}
objectLoader.load(mockDomainObjects.a, shortName)
.then(mockCallback);
// Should have pruned 'ba' and 'ca'
expect(mockCallback).toHaveBeenCalledWith({
domainObject: mockDomainObjects.a,
composition: [
{
domainObject: mockDomainObjects.b,
composition: [
{
domainObject: mockDomainObjects.c,
composition: []
},
fullSubgraph('d')
]
},
{
domainObject: mockDomainObjects.c,
composition: []
},
fullSubgraph('d')
]
});
});
});
}
);

View File

@@ -0,0 +1,50 @@
[
"TimelineConstants",
"TimelineFormatter",
"capabilities/ActivityTimespan",
"capabilities/ActivityTimespanCapability",
"capabilities/ActivityUtilization",
"capabilities/CostCapability",
"capabilities/GraphCapability",
"capabilities/CumulativeGraph",
"capabilities/ResourceGraph",
"capabilities/TimelineTimespan",
"capabilities/TimelineTimespanCapability",
"capabilities/TimelineUtilization",
"capabilities/UtilizationCapability",
"controllers/ActivityModeValuesController",
"controllers/TimelineController",
"controllers/TimelineGanttController",
"controllers/TimelineGraphController",
"controllers/TimelineTableController",
"controllers/TimelineTickController",
"controllers/TimelineZoomController",
"controllers/WARPDateTimeController",
"controllers/drag/TimelineDragHandler",
"controllers/drag/TimelineDragHandleFactory",
"controllers/drag/TimelineDragPopulator",
"controllers/drag/TimelineSnapHandler",
"controllers/drag/TimelineStartHandle",
"controllers/drag/TimelineMoveHandle",
"controllers/drag/TimelineEndHandle",
"controllers/graph/TimelineGraph",
"controllers/graph/TimelineGraphPopulator",
"controllers/graph/TimelineGraphRenderer",
"controllers/swimlane/TimelineColorAssigner",
"controllers/swimlane/TimelineProxy",
"controllers/swimlane/TimelineSwimlane",
"controllers/swimlane/TimelineSwimlaneDecorator",
"controllers/swimlane/TimelineSwimlaneDropHandler",
"controllers/swimlane/TimelineSwimlanePopulator",
"directives/SwimlaneDragConstants",
"directives/WARPSwimlaneDrag",
"directives/WARPSwimlaneDrop",
"services/ObjectLoader"
]