Browse Source

Merge branch 'develop' of https://github.com/almende/vis into develop

Conflicts:
	dist/vis.js
css_transitions
Alex de Mulder 10 years ago
parent
commit
3d0f3aae69
47 changed files with 22206 additions and 5718 deletions
  1. +26
    -4
      HISTORY.md
  2. +0
    -1
      Jakefile.js
  3. +1
    -1
      bower.json
  4. +62
    -44
      dist/vis.css
  5. +2211
    -2792
      dist/vis.js
  6. +17549
    -0
      dist/vis.js.tmp
  7. +1
    -1
      dist/vis.min.css
  8. +11
    -11
      dist/vis.min.js
  9. +6
    -1
      docs/dataset.html
  10. +3
    -2
      docs/timeline.html
  11. +1
    -1
      examples/timeline/03_much_data.html
  12. +1
    -1
      examples/timeline/05_groups.html
  13. +1
    -2
      examples/timeline/08_edit_items.html
  14. +65
    -0
      examples/timeline/09_order_groups.html
  15. +51
    -0
      examples/timeline/10_limit_move_and_zoom.html
  16. +4
    -0
      examples/timeline/index.html
  17. +1
    -1
      package.json
  18. +14
    -4
      src/DataSet.js
  19. +0
    -1
      src/module/exports.js
  20. +0
    -183
      src/timeline/Controller.js
  21. +53
    -93
      src/timeline/Range.js
  22. +79
    -100
      src/timeline/Stack.js
  23. +312
    -188
      src/timeline/Timeline.js
  24. +17
    -95
      src/timeline/component/Component.js
  25. +0
    -113
      src/timeline/component/ContentPanel.js
  26. +54
    -59
      src/timeline/component/CurrentTime.js
  27. +55
    -83
      src/timeline/component/CustomTime.js
  28. +135
    -51
      src/timeline/component/Group.js
  29. +189
    -304
      src/timeline/component/GroupSet.js
  30. +287
    -389
      src/timeline/component/ItemSet.js
  31. +118
    -60
      src/timeline/component/Panel.js
  32. +79
    -132
      src/timeline/component/RootPanel.js
  33. +191
    -277
      src/timeline/component/TimeAxis.js
  34. +16
    -32
      src/timeline/component/css/groupset.css
  35. +12
    -1
      src/timeline/component/css/item.css
  36. +11
    -4
      src/timeline/component/css/itemset.css
  37. +16
    -0
      src/timeline/component/css/panel.css
  38. +8
    -8
      src/timeline/component/css/timeaxis.css
  39. +14
    -18
      src/timeline/component/item/Item.js
  40. +153
    -226
      src/timeline/component/item/ItemBox.js
  41. +114
    -163
      src/timeline/component/item/ItemPoint.js
  42. +128
    -179
      src/timeline/component/item/ItemRange.js
  43. +26
    -88
      src/timeline/component/item/ItemRangeOverflow.js
  44. +42
    -3
      src/util.js
  45. +15
    -0
      test/dataset.js
  46. +41
    -2
      test/timeline.html
  47. +33
    -0
      test/timeline_groups.html

+ 26
- 4
HISTORY.md View File

@ -1,8 +1,31 @@
vis.js history
# vis.js history
http://visjs.org http://visjs.org
## 2014-04-16, version 0.7.4
## not yet released, version 0.8.0
### Timeline
- Large refactoring of the Timeline, simplifying the code.
- Performance improvements.
- Improved layout of box-items inside groups.
- Function `setWindow` now accepts an object with properties `start` and `end`.
- Fixed option `autoResize` forcing a repaint of the Timeline with every check
rather than when the Timeline is actually resized.
- Fixed `select` event fired repeatedly when clicking an empty place on the
Timeline, deselecting selected items).
- Fixed initial visible window in case items exceed `zoomMax`. Thanks @Remper.
- Option `order` is now deprecated. This was needed for performance improvements.
- Minor bug fixes.
- More examples added.
### DataSet
- A DataSet can now be constructed with initial data, like
`new DataSet(data, options)`.
## 2014-04-18, version 0.7.4
### Graph ### Graph
@ -11,7 +34,6 @@ http://visjs.org
- minor bug fixes. - minor bug fixes.
## 2014-04-16, version 0.7.3 ## 2014-04-16, version 0.7.3
### Graph ### Graph
@ -142,7 +164,7 @@ http://visjs.org
- Moved the generated library to folder `./dist` - Moved the generated library to folder `./dist`
- Css stylesheet must be loaded explicitly now. - Css stylesheet must be loaded explicitly now.
- Implemented options `showCurrentTime` and `showCustomTime`. Thanks fi0dor.
- Implemented options `showCurrentTime` and `showCustomTime`. Thanks @fi0dor.
- Implemented touch support for Timeline. - Implemented touch support for Timeline.
- Fixed broken Timeline options `min` and `max`. - Fixed broken Timeline options `min` and `max`.
- Fixed not being able to load vis.js in node.js. - Fixed not being able to load vis.js in node.js.

+ 0
- 1
Jakefile.js View File

@ -67,7 +67,6 @@ task('build', {async: true}, function () {
'./src/timeline/TimeStep.js', './src/timeline/TimeStep.js',
'./src/timeline/Stack.js', './src/timeline/Stack.js',
'./src/timeline/Range.js', './src/timeline/Range.js',
'./src/timeline/Controller.js',
'./src/timeline/component/Component.js', './src/timeline/component/Component.js',
'./src/timeline/component/Panel.js', './src/timeline/component/Panel.js',
'./src/timeline/component/RootPanel.js', './src/timeline/component/RootPanel.js',

+ 1
- 1
bower.json View File

@ -1,6 +1,6 @@
{ {
"name": "vis", "name": "vis",
"version": "0.7.4-SNAPSHOT",
"version": "0.7.5-SNAPSHOT",
"description": "A dynamic, browser-based visualization library.", "description": "A dynamic, browser-based visualization library.",
"homepage": "http://visjs.org/", "homepage": "http://visjs.org/",
"repository": { "repository": {

+ 62
- 44
dist/vis.css View File

@ -9,79 +9,86 @@
border: 1px solid #bfbfbf; border: 1px solid #bfbfbf;
-moz-box-sizing: border-box; -moz-box-sizing: border-box;
box-sizing: border-box; box-sizing: border-box;
/* FIXME: there is an issue with the height of the items when panel height is animated
-webkit-transition: height 4s ease-in-out;
transition: height 4s ease-in-out;
/**/
} }
.vis.timeline .vpanel { .vis.timeline .vpanel {
position: absolute; position: absolute;
overflow: hidden; overflow: hidden;
}
.vis.timeline .groupset {
position: absolute;
padding: 0;
margin: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
} }
.vis.timeline .labels {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.vis.timeline .vpanel.side {
border-right: 1px solid #bfbfbf;
}
padding: 0;
margin: 0;
.vis.timeline .vpanel.side.hidden {
display: none;
}
border-right: 1px solid #bfbfbf;
-moz-box-sizing: border-box;
box-sizing: border-box;
.vis.timeline .groupset {
position: relative;
} }
.vis.timeline .labels .label-set {
position: absolute;
top: 0;
left: 0;
.vis.timeline .labelset {
position: relative;
width: 100%; width: 100%;
height: 100%;
overflow: hidden; overflow: hidden;
border-top: none;
border-bottom: 1px solid #bfbfbf;
-moz-box-sizing: border-box;
box-sizing: border-box;
} }
.vis.timeline .labels .label-set .vlabel {
position: absolute;
.vis.timeline .labelset .vlabel {
position: relative;
left: 0; left: 0;
top: 0; top: 0;
width: 100%; width: 100%;
color: #4d4d4d; color: #4d4d4d;
-moz-box-sizing: border-box;
box-sizing: border-box;
} }
.vis.timeline.top .labels .label-set .vlabel,
.vis.timeline.top .groupset .itemset-axis {
.vis.timeline.bottom .labelset .vlabel,
.vis.timeline.top .vpanel.side-content,
.vis.timeline.top .groupset .itemset {
border-top: 1px solid #bfbfbf; border-top: 1px solid #bfbfbf;
border-bottom: none; border-bottom: none;
} }
.vis.timeline.bottom .labels .label-set .vlabel,
.vis.timeline.bottom .groupset .itemset-axis {
.vis.timeline.top .labelset .vlabel,
.vis.timeline.bottom .vpanel.side-content,
.vis.timeline.bottom .groupset .itemset {
border-top: none; border-top: none;
border-bottom: 1px solid #bfbfbf; border-bottom: 1px solid #bfbfbf;
} }
.vis.timeline .labels .label-set .vlabel .inner {
.vis.timeline .labelset .vlabel .inner {
display: inline-block; display: inline-block;
padding: 5px; padding: 5px;
} }
.vis.timeline .itemset { .vis.timeline .itemset {
position: absolute;
position: relative;
padding: 0; padding: 0;
margin: 0; margin: 0;
overflow: hidden;
-moz-box-sizing: border-box;
box-sizing: border-box;
/* FIXME: get transition working for rootpanel and itemset
-webkit-transition: height 4s ease-in-out;
transition: height 4s ease-in-out;
/**/
} }
.vis.timeline .background { .vis.timeline .background {
@ -90,8 +97,8 @@
.vis.timeline .foreground { .vis.timeline .foreground {
} }
.vis.timeline .itemset-axis {
position: absolute;
.vis.timeline .axis {
overflow: visible;
} }
@ -102,6 +109,11 @@
background-color: #D5DDF6; background-color: #D5DDF6;
display: inline-block; display: inline-block;
padding: 5px; padding: 5px;
/* TODO: enable css transitions
-webkit-transition: top .4s ease-in-out, bottom .4s ease-in-out;
transition: top .4s ease-in-out, bottom .4s ease-in-out;
/**/
} }
.vis.timeline .item.selected { .vis.timeline .item.selected {
@ -118,7 +130,8 @@
background-color: #FFF785; background-color: #FFF785;
z-index: 999; z-index: 999;
} }
.vis.timeline .item.point.selected .dot {
.vis.timeline .item.point.selected .dot,
.vis.timeline .item.dot.selected {
border-color: #FFC200; border-color: #FFC200;
} }
@ -179,6 +192,11 @@
width: 0; width: 0;
border-left-width: 1px; border-left-width: 1px;
border-left-style: solid; border-left-style: solid;
/* TODO: enable css transitions
-webkit-transition: height .4s ease-in-out, top .4s ease-in-out;
transition: height .4s ease-in-out, top .4s ease-in-out;
/**/
} }
.vis.timeline .item .content { .vis.timeline .item .content {
@ -220,18 +238,18 @@
z-index: 10001; /* a little higher z-index than .drag-left */ z-index: 10001; /* a little higher z-index than .drag-left */
} }
.vis.timeline .axis {
position: relative;
.vis.timeline .timeaxis {
position: absolute;
} }
.vis.timeline .axis .text {
.vis.timeline .timeaxis .text {
position: absolute; position: absolute;
color: #4d4d4d; color: #4d4d4d;
padding: 3px; padding: 3px;
white-space: nowrap; white-space: nowrap;
} }
.vis.timeline .axis .text.measure {
.vis.timeline .timeaxis .text.measure {
position: absolute; position: absolute;
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
@ -240,13 +258,13 @@
visibility: hidden; visibility: hidden;
} }
.vis.timeline .axis .grid.vertical {
.vis.timeline .timeaxis .grid.vertical {
position: absolute; position: absolute;
width: 0; width: 0;
border-right: 1px solid; border-right: 1px solid;
} }
.vis.timeline .axis .grid.horizontal {
.vis.timeline .timeaxis .grid.horizontal {
position: absolute; position: absolute;
left: 0; left: 0;
width: 100%; width: 100%;
@ -254,11 +272,11 @@
border-bottom: 1px solid; border-bottom: 1px solid;
} }
.vis.timeline .axis .grid.minor {
.vis.timeline .timeaxis .grid.minor {
border-color: #e5e5e5; border-color: #e5e5e5;
} }
.vis.timeline .axis .grid.major {
.vis.timeline .timeaxis .grid.major {
border-color: #bfbfbf; border-color: #bfbfbf;
} }

+ 2211
- 2792
dist/vis.js
File diff suppressed because it is too large
View File


+ 17549
- 0
dist/vis.js.tmp
File diff suppressed because it is too large
View File


+ 1
- 1
dist/vis.min.css
File diff suppressed because it is too large
View File


+ 11
- 11
dist/vis.min.js
File diff suppressed because it is too large
View File


+ 6
- 1
docs/dataset.html View File

@ -107,7 +107,7 @@ console.log('formatted items', items);
</p> </p>
<pre class="prettyprint lang-js"> <pre class="prettyprint lang-js">
var data = new vis.DataSet(options)
var data = new vis.DataSet([data] [, options])
</pre> </pre>
<p> <p>
@ -116,6 +116,11 @@ var data = new vis.DataSet(options)
<a href="#Data_Manipulation">Data Manipulation</a>. <a href="#Data_Manipulation">Data Manipulation</a>.
</p> </p>
<p>
The parameter <code>data</code>code> is optional and can be an Array or
Google DataTable with items.
</p>
<p> <p>
The parameter <code>options</code> is optional and is an object which can The parameter <code>options</code> is optional and is an object which can
contain the following properties: contain the following properties:

+ 3
- 2
docs/timeline.html View File

@ -456,6 +456,7 @@ var options = {
</td> </td>
</tr> </tr>
<!-- TODO: cleanup option order
<tr> <tr>
<td>order</td> <td>order</td>
<td>Function</td> <td>Function</td>
@ -466,6 +467,7 @@ var options = {
`vis.components.items.Item`. `vis.components.items.Item`.
</td> </td>
</tr> </tr>
-->
<tr> <tr>
<td>orientation</td> <td>orientation</td>
@ -483,8 +485,7 @@ var options = {
<pre class="prettyprint lang-css"> <pre class="prettyprint lang-css">
.vis.timeline .item { .vis.timeline .item {
padding: 10px; padding: 10px;
}
</pre>
}</pre>
</td> </td>
</tr> </tr>

+ 1
- 1
examples/timeline/03_much_data.html View File

@ -22,7 +22,7 @@
</h1> </h1>
<p> <p>
<label for="count">Number of items</label> <label for="count">Number of items</label>
<input id="count" value="100">
<input id="count" value="1000">
<input id="draw" type="button" value="draw"> <input id="draw" type="button" value="draw">
</p> </p>
<div id="visualization"></div> <div id="visualization"></div>

+ 1
- 1
examples/timeline/05_groups.html View File

@ -60,7 +60,7 @@
// create visualization // create visualization
var container = document.getElementById('visualization'); var container = document.getElementById('visualization');
var options = { var options = {
groupOrder: 'content'
groupOrder: 'content' // groupOrder can be a property name or a sorting function
}; };
var timeline = new vis.Timeline(container); var timeline = new vis.Timeline(container);

+ 1
- 2
examples/timeline/08_edit_items.html View File

@ -18,8 +18,7 @@
<div id="log"></div> <div id="log"></div>
<script type="text/javascript"> <script type="text/javascript">
var items = new vis.DataSet();
items.add([
var items = new vis.DataSet([
{id: 1, content: 'item 1', start: new Date(2013, 3, 20)}, {id: 1, content: 'item 1', start: new Date(2013, 3, 20)},
{id: 2, content: 'item 2', start: new Date(2013, 3, 14)}, {id: 2, content: 'item 2', start: new Date(2013, 3, 14)},
{id: 3, content: 'item 3', start: new Date(2013, 3, 18)}, {id: 3, content: 'item 3', start: new Date(2013, 3, 18)},

+ 65
- 0
examples/timeline/09_order_groups.html View File

@ -0,0 +1,65 @@
<!DOCTYPE HTML>
<html>
<head>
<title>Timeline | Order groups</title>
<style>
body, html {
font-family: arial, sans-serif;
font-size: 11pt;
}
#visualization {
box-sizing: border-box;
width: 100%;
height: 300px;
}
</style>
<script src="../../dist/vis.js"></script>
<link href="../../dist/vis.css" rel="stylesheet" type="text/css" />
</head>
<body>
<p>
This example demonstrate custom ordering of groups.
</p>
<div id="visualization"></div>
<script>
var groups = new vis.DataSet([
{id: 0, content: 'First', value: 1},
{id: 1, content: 'Third', value: 3},
{id: 2, content: 'Second', value: 2}
]);
// create a dataset with items
var items = new vis.DataSet([
{id: 0, group: 0, content: 'item 0', start: new Date(2014, 3, 17)},
{id: 1, group: 0, content: 'item 1', start: new Date(2014, 3, 19)},
{id: 2, group: 1, content: 'item 2', start: new Date(2014, 3, 16)},
{id: 3, group: 1, content: 'item 3', start: new Date(2014, 3, 23)},
{id: 4, group: 1, content: 'item 4', start: new Date(2014, 3, 22)},
{id: 5, group: 2, content: 'item 5', start: new Date(2014, 3, 24)}
]);
// create visualization
var container = document.getElementById('visualization');
var options = {
// option groupOrder can be a property name or a sort function
// the sort function must compare two groups and return a value
// > 0 when a > b
// < 0 when a < b
// 0 when a == b
groupOrder: function (a, b) {
return a.value - b.value;
}
};
var timeline = new vis.Timeline(container);
timeline.setOptions(options);
timeline.setGroups(groups);
timeline.setItems(items);
</script>
</body>
</html>

+ 51
- 0
examples/timeline/10_limit_move_and_zoom.html View File

@ -0,0 +1,51 @@
<!DOCTYPE HTML>
<html>
<head>
<title>Timeline | Limit move and zoom</title>
<style>
body, html {
font-family: arial, sans-serif;
font-size: 11pt;
}
</style>
<script src="../../dist/vis.js"></script>
<link href="../../dist/vis.css" rel="stylesheet" type="text/css" />
</head>
<body>
<p>
The visible range is limited in this demo:
</p>
<ul>
<li>minimum visible date is limited to 2012-01-01 using option <code>min</code></li>
<li>maximum visible date is limited to 2013-01-01 (excluded) using option <code>max</code></li>
<li>visible zoom interval is limited to a minimum of 24 hours using option <code>zoomMin</code></li>
<li>visible zoom interval is limited to a maximum of about 3 months using option <code>zoomMax</code></li>
</ul>
<div id="visualization"></div>
<script>
// create some items
var items = [
{'start': new Date(2012, 4, 25), 'content': 'First'},
{'start': new Date(2012, 4, 26), 'content': 'Last'}
];
// create visualization
var container = document.getElementById('visualization');
var options = {
height: '300px',
min: new Date(2012, 0, 1), // lower limit of visible range
max: new Date(2013, 0, 1), // upper limit of visible range
zoomMin: 1000 * 60 * 60 * 24, // one day in milliseconds
zoomMax: 1000 * 60 * 60 * 24 * 31 * 3 // about three months in milliseconds
};
// create the timeline
var timeline = new vis.Timeline(container);
timeline.setOptions(options);
timeline.setItems(items);
</script>
</body>
</html>

+ 4
- 0
examples/timeline/index.html View File

@ -20,6 +20,10 @@
<p><a href="06_event_listeners.html">06_event_listeners.html</a></p> <p><a href="06_event_listeners.html">06_event_listeners.html</a></p>
<p><a href="07_custom_time_bar.html">07_custom_time_bar.html</a></p> <p><a href="07_custom_time_bar.html">07_custom_time_bar.html</a></p>
<p><a href="08_edit_items.html">08_edit_items.html</a></p> <p><a href="08_edit_items.html">08_edit_items.html</a></p>
<p><a href="09_order_groups.html">09_order_groups.html</a></p>
<p><a href="10_limit_move_and_zoom.html">10_limit_range_and_zoom.html</a></p>
<p><a href="requirejs/requirejs_example.html">requirejs_example.html</a></p>
</div> </div>
</body> </body>

+ 1
- 1
package.json View File

@ -1,6 +1,6 @@
{ {
"name": "vis", "name": "vis",
"version": "0.7.4-SNAPSHOT",
"version": "0.7.5-SNAPSHOT",
"description": "A dynamic, browser-based visualization library.", "description": "A dynamic, browser-based visualization library.",
"homepage": "http://visjs.org/", "homepage": "http://visjs.org/",
"repository": { "repository": {

+ 14
- 4
src/DataSet.js View File

@ -26,6 +26,7 @@
* - gives triggers upon changes in the data * - gives triggers upon changes in the data
* - can import/export data in various data formats * - can import/export data in various data formats
* *
* @param {Array | DataTable} [data] Optional array with initial data
* @param {Object} [options] Available options: * @param {Object} [options] Available options:
* {String} fieldId Field name of the id in the * {String} fieldId Field name of the id in the
* items, 'id' by default. * items, 'id' by default.
@ -35,9 +36,15 @@
* @constructor DataSet * @constructor DataSet
*/ */
// TODO: add a DataSet constructor DataSet(data, options) // TODO: add a DataSet constructor DataSet(data, options)
function DataSet (options) {
function DataSet (data, options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
// correctly read optional arguments
if (data && !Array.isArray(data) && !util.isDataTable(data)) {
options = data;
data = null;
}
this.options = options || {}; this.options = options || {};
this.data = {}; // map with data indexed by id this.data = {}; // map with data indexed by id
this.fieldId = this.options.fieldId || 'id'; // name of the field containing id this.fieldId = this.options.fieldId || 'id'; // name of the field containing id
@ -58,10 +65,13 @@ function DataSet (options) {
} }
} }
// event subscribers
this.subscribers = {};
this.subscribers = {}; // event subscribers
this.internalIds = {}; // internally generated id's
this.internalIds = {}; // internally generated id's
// add initial data when provided
if (data) {
this.add(data);
}
} }
/** /**

+ 0
- 1
src/module/exports.js View File

@ -4,7 +4,6 @@
var vis = { var vis = {
util: util, util: util,
Controller: Controller,
DataSet: DataSet, DataSet: DataSet,
DataView: DataView, DataView: DataView,
Range: Range, Range: Range,

+ 0
- 183
src/timeline/Controller.js View File

@ -1,183 +0,0 @@
/**
* @constructor Controller
*
* A Controller controls the reflows and repaints of all components,
* and is used as an event bus for all components.
*/
function Controller () {
var me = this;
this.id = util.randomUUID();
this.components = {};
/**
* Listen for a 'request-reflow' event. The controller will schedule a reflow
* @param {Boolean} [force] If true, an immediate reflow is forced. Default
* is false.
*/
var reflowTimer = null;
this.on('request-reflow', function requestReflow(force) {
if (force) {
me.reflow();
}
else {
if (!reflowTimer) {
reflowTimer = setTimeout(function () {
reflowTimer = null;
me.reflow();
}, 0);
}
}
});
/**
* Request a repaint. The controller will schedule a repaint
* @param {Boolean} [force] If true, an immediate repaint is forced. Default
* is false.
*/
var repaintTimer = null;
this.on('request-repaint', function requestRepaint(force) {
if (force) {
me.repaint();
}
else {
if (!repaintTimer) {
repaintTimer = setTimeout(function () {
repaintTimer = null;
me.repaint();
}, 0);
}
}
});
}
// Extend controller with Emitter mixin
Emitter(Controller.prototype);
/**
* Add a component to the controller
* @param {Component} component
*/
Controller.prototype.add = function add(component) {
// validate the component
if (component.id == undefined) {
throw new Error('Component has no field id');
}
if (!(component instanceof Component) && !(component instanceof Controller)) {
throw new TypeError('Component must be an instance of ' +
'prototype Component or Controller');
}
// add the component
component.setController(this);
this.components[component.id] = component;
};
/**
* Remove a component from the controller
* @param {Component | String} component
*/
Controller.prototype.remove = function remove(component) {
var id;
for (id in this.components) {
if (this.components.hasOwnProperty(id)) {
if (id == component || this.components[id] === component) {
break;
}
}
}
if (id) {
// unregister the controller (gives the component the ability to unregister
// event listeners and clean up other stuff)
this.components[id].setController(null);
delete this.components[id];
}
};
/**
* Repaint all components
*/
Controller.prototype.repaint = function repaint() {
var changed = false;
// cancel any running repaint request
if (this.repaintTimer) {
clearTimeout(this.repaintTimer);
this.repaintTimer = undefined;
}
var done = {};
function repaint(component, id) {
if (!(id in done)) {
// first repaint the components on which this component is dependent
if (component.depends) {
component.depends.forEach(function (dep) {
repaint(dep, dep.id);
});
}
if (component.parent) {
repaint(component.parent, component.parent.id);
}
// repaint the component itself and mark as done
changed = component.repaint() || changed;
done[id] = true;
}
}
util.forEach(this.components, repaint);
this.emit('repaint');
// immediately reflow when needed
if (changed) {
this.reflow();
}
// TODO: limit the number of nested reflows/repaints, prevent loop
};
/**
* Reflow all components
*/
Controller.prototype.reflow = function reflow() {
var resized = false;
// cancel any running repaint request
if (this.reflowTimer) {
clearTimeout(this.reflowTimer);
this.reflowTimer = undefined;
}
var done = {};
function reflow(component, id) {
if (!(id in done)) {
// first reflow the components on which this component is dependent
if (component.depends) {
component.depends.forEach(function (dep) {
reflow(dep, dep.id);
});
}
if (component.parent) {
reflow(component.parent, component.parent.id);
}
// reflow the component itself and mark as done
resized = component.reflow() || resized;
done[id] = true;
}
}
util.forEach(this.components, reflow);
this.emit('reflow');
// immediately repaint when needed
if (resized) {
this.repaint();
}
// TODO: limit the number of nested reflows/repaints, prevent loop
};

+ 53
- 93
src/timeline/Range.js View File

@ -3,20 +3,39 @@
* A Range controls a numeric range with a start and end value. * A Range controls a numeric range with a start and end value.
* The Range adjusts the range based on mouse events or programmatic changes, * The Range adjusts the range based on mouse events or programmatic changes,
* and triggers events when the range is changing or has been changed. * and triggers events when the range is changing or has been changed.
* @param {Object} [options] See description at Range.setOptions
* @extends Controller
* @param {RootPanel} root Root panel, used to subscribe to events
* @param {Panel} parent Parent panel, used to attach to the DOM
* @param {Object} [options] See description at Range.setOptions
*/ */
function Range(options) {
function Range(root, parent, options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
this.start = null; // Number this.start = null; // Number
this.end = null; // Number this.end = null; // Number
this.root = root;
this.parent = parent;
this.options = options || {}; this.options = options || {};
// drag listeners for dragging
this.root.on('dragstart', this._onDragStart.bind(this));
this.root.on('drag', this._onDrag.bind(this));
this.root.on('dragend', this._onDragEnd.bind(this));
// ignore dragging when holding
this.root.on('hold', this._onHold.bind(this));
// mouse wheel for zooming
this.root.on('mousewheel', this._onMouseWheel.bind(this));
this.root.on('DOMMouseScroll', this._onMouseWheel.bind(this)); // For FF
// pinch to zoom
this.root.on('touch', this._onTouch.bind(this));
this.root.on('pinch', this._onPinch.bind(this));
this.setOptions(options); this.setOptions(options);
} }
// extend the Range prototype with an event emitter mixin
// turn Range into an event emitter
Emitter(Range.prototype); Emitter(Range.prototype);
/** /**
@ -49,59 +68,6 @@ function validateDirection (direction) {
} }
} }
/**
* Add listeners for mouse and touch events to the component
* @param {Controller} controller
* @param {Component} component Should be a rootpanel
* @param {String} event Available events: 'move', 'zoom'
* @param {String} direction Available directions: 'horizontal', 'vertical'
*/
Range.prototype.subscribe = function (controller, component, event, direction) {
var me = this;
if (event == 'move') {
// drag start listener
controller.on('dragstart', function (event) {
me._onDragStart(event, component);
});
// drag listener
controller.on('drag', function (event) {
me._onDrag(event, component, direction);
});
// drag end listener
controller.on('dragend', function (event) {
me._onDragEnd(event, component);
});
// ignore dragging when holding
controller.on('hold', function (event) {
me._onHold();
});
}
else if (event == 'zoom') {
// mouse wheel
function mousewheel (event) {
me._onMouseWheel(event, component, direction);
}
controller.on('mousewheel', mousewheel);
controller.on('DOMMouseScroll', mousewheel); // For FF
// pinch
controller.on('touch', function (event) {
me._onTouch(event);
});
controller.on('pinch', function (event) {
me._onPinch(event, component, direction);
});
}
else {
throw new TypeError('Unknown event "' + event + '". ' +
'Choose "move" or "zoom".');
}
};
/** /**
* Set a new start and end range * Set a new start and end range
* @param {Number} [start] * @param {Number} [start]
@ -111,8 +77,8 @@ Range.prototype.setRange = function(start, end) {
var changed = this._applyRange(start, end); var changed = this._applyRange(start, end);
if (changed) { if (changed) {
var params = { var params = {
start: this.start,
end: this.end
start: new Date(this.start),
end: new Date(this.end)
}; };
this.emit('rangechange', params); this.emit('rangechange', params);
this.emit('rangechanged', params); this.emit('rangechanged', params);
@ -280,10 +246,9 @@ var touchParams = {};
/** /**
* Start dragging horizontally or vertically * Start dragging horizontally or vertically
* @param {Event} event * @param {Event} event
* @param {Object} component
* @private * @private
*/ */
Range.prototype._onDragStart = function(event, component) {
Range.prototype._onDragStart = function(event) {
// refuse to drag when we where pinching to prevent the timeline make a jump // refuse to drag when we where pinching to prevent the timeline make a jump
// when releasing the fingers in opposite order from the touch screen // when releasing the fingers in opposite order from the touch screen
if (touchParams.ignore) return; if (touchParams.ignore) return;
@ -293,7 +258,7 @@ Range.prototype._onDragStart = function(event, component) {
touchParams.start = this.start; touchParams.start = this.start;
touchParams.end = this.end; touchParams.end = this.end;
var frame = component.frame;
var frame = this.parent.frame;
if (frame) { if (frame) {
frame.style.cursor = 'move'; frame.style.cursor = 'move';
} }
@ -302,11 +267,10 @@ Range.prototype._onDragStart = function(event, component) {
/** /**
* Perform dragging operating. * Perform dragging operating.
* @param {Event} event * @param {Event} event
* @param {Component} component
* @param {String} direction 'horizontal' or 'vertical'
* @private * @private
*/ */
Range.prototype._onDrag = function (event, component, direction) {
Range.prototype._onDrag = function (event) {
var direction = this.options.direction;
validateDirection(direction); validateDirection(direction);
// TODO: reckon with option movable // TODO: reckon with option movable
@ -318,38 +282,37 @@ Range.prototype._onDrag = function (event, component, direction) {
var delta = (direction == 'horizontal') ? event.gesture.deltaX : event.gesture.deltaY, var delta = (direction == 'horizontal') ? event.gesture.deltaX : event.gesture.deltaY,
interval = (touchParams.end - touchParams.start), interval = (touchParams.end - touchParams.start),
width = (direction == 'horizontal') ? component.width : component.height,
width = (direction == 'horizontal') ? this.parent.width : this.parent.height,
diffRange = -delta / width * interval; diffRange = -delta / width * interval;
this._applyRange(touchParams.start + diffRange, touchParams.end + diffRange); this._applyRange(touchParams.start + diffRange, touchParams.end + diffRange);
this.emit('rangechange', { this.emit('rangechange', {
start: this.start,
end: this.end
start: new Date(this.start),
end: new Date(this.end)
}); });
}; };
/** /**
* Stop dragging operating. * Stop dragging operating.
* @param {event} event * @param {event} event
* @param {Component} component
* @private * @private
*/ */
Range.prototype._onDragEnd = function (event, component) {
Range.prototype._onDragEnd = function (event) {
// refuse to drag when we where pinching to prevent the timeline make a jump // refuse to drag when we where pinching to prevent the timeline make a jump
// when releasing the fingers in opposite order from the touch screen // when releasing the fingers in opposite order from the touch screen
if (touchParams.ignore) return; if (touchParams.ignore) return;
// TODO: reckon with option movable // TODO: reckon with option movable
if (component.frame) {
component.frame.style.cursor = 'auto';
if (this.parent.frame) {
this.parent.frame.style.cursor = 'auto';
} }
// fire a rangechanged event // fire a rangechanged event
this.emit('rangechanged', { this.emit('rangechanged', {
start: this.start,
end: this.end
start: new Date(this.start),
end: new Date(this.end)
}); });
}; };
@ -357,13 +320,9 @@ Range.prototype._onDragEnd = function (event, component) {
* Event handler for mouse wheel event, used to zoom * Event handler for mouse wheel event, used to zoom
* Code from http://adomas.org/javascript-mouse-wheel/ * Code from http://adomas.org/javascript-mouse-wheel/
* @param {Event} event * @param {Event} event
* @param {Component} component
* @param {String} direction 'horizontal' or 'vertical'
* @private * @private
*/ */
Range.prototype._onMouseWheel = function(event, component, direction) {
validateDirection(direction);
Range.prototype._onMouseWheel = function(event) {
// TODO: reckon with option zoomable // TODO: reckon with option zoomable
// retrieve delta // retrieve delta
@ -394,8 +353,8 @@ Range.prototype._onMouseWheel = function(event, component, direction) {
// calculate center, the date to zoom around // calculate center, the date to zoom around
var gesture = util.fakeGesture(this, event), var gesture = util.fakeGesture(this, event),
pointer = getPointer(gesture.center, component.frame),
pointerDate = this._pointerToDate(component, direction, pointer);
pointer = getPointer(gesture.center, this.parent.frame),
pointerDate = this._pointerToDate(pointer);
this.zoom(scale, pointerDate); this.zoom(scale, pointerDate);
} }
@ -434,24 +393,23 @@ Range.prototype._onHold = function () {
/** /**
* Handle pinch event * Handle pinch event
* @param {Event} event * @param {Event} event
* @param {Component} component
* @param {String} direction 'horizontal' or 'vertical'
* @private * @private
*/ */
Range.prototype._onPinch = function (event, component, direction) {
Range.prototype._onPinch = function (event) {
var direction = this.options.direction;
touchParams.ignore = true; touchParams.ignore = true;
// TODO: reckon with option zoomable // TODO: reckon with option zoomable
if (event.gesture.touches.length > 1) { if (event.gesture.touches.length > 1) {
if (!touchParams.center) { if (!touchParams.center) {
touchParams.center = getPointer(event.gesture.center, component.frame);
touchParams.center = getPointer(event.gesture.center, this.parent.frame);
} }
var scale = 1 / event.gesture.scale, var scale = 1 / event.gesture.scale,
initDate = this._pointerToDate(component, direction, touchParams.center),
center = getPointer(event.gesture.center, component.frame),
date = this._pointerToDate(component, direction, center),
initDate = this._pointerToDate(touchParams.center),
center = getPointer(event.gesture.center, this.parent.frame),
date = this._pointerToDate(this.parent, center),
delta = date - initDate; // TODO: utilize delta delta = date - initDate; // TODO: utilize delta
// calculate new start and end // calculate new start and end
@ -465,21 +423,23 @@ Range.prototype._onPinch = function (event, component, direction) {
/** /**
* Helper function to calculate the center date for zooming * Helper function to calculate the center date for zooming
* @param {Component} component
* @param {{x: Number, y: Number}} pointer * @param {{x: Number, y: Number}} pointer
* @param {String} direction 'horizontal' or 'vertical'
* @return {number} date * @return {number} date
* @private * @private
*/ */
Range.prototype._pointerToDate = function (component, direction, pointer) {
Range.prototype._pointerToDate = function (pointer) {
var conversion; var conversion;
var direction = this.options.direction;
validateDirection(direction);
if (direction == 'horizontal') { if (direction == 'horizontal') {
var width = component.width;
var width = this.parent.width;
conversion = this.conversion(width); conversion = this.conversion(width);
return pointer.x / conversion.scale + conversion.offset; return pointer.x / conversion.scale + conversion.offset;
} }
else { else {
var height = component.height;
var height = this.parent.height;
conversion = this.conversion(height); conversion = this.conversion(height);
return pointer.y / conversion.scale + conversion.offset; return pointer.y / conversion.scale + conversion.offset;
} }

+ 79
- 100
src/timeline/Stack.js View File

@ -1,18 +1,16 @@
// TODO: turn Stack into a Mixin?
/** /**
* @constructor Stack * @constructor Stack
* Stacks items on top of each other. * Stacks items on top of each other.
* @param {ItemSet} itemset
* @param {Object} [options] * @param {Object} [options]
*/ */
function Stack (itemset, options) {
this.itemset = itemset;
function Stack (options) {
this.options = options || {}; this.options = options || {};
this.defaultOptions = { this.defaultOptions = {
order: function (a, b) { order: function (a, b) {
//return (b.width - a.width) || (a.left - b.left); // TODO: cleanup
// Order: ranges over non-ranges, ranged ordered by width, and
// lastly ordered by start.
// Order: ranges over non-ranges, ranged ordered by width,
// and non-ranges ordered by start.
if (a instanceof ItemRange) { if (a instanceof ItemRange) {
if (b instanceof ItemRange) { if (b instanceof ItemRange) {
var aInt = (a.data.end - a.data.start); var aInt = (a.data.end - a.data.start);
@ -33,141 +31,122 @@ function Stack (itemset, options) {
} }
}, },
margin: { margin: {
item: 10
item: 10,
axis: 20
} }
}; };
this.ordered = []; // ordered items
} }
/** /**
* Set options for the stack * Set options for the stack
* @param {Object} options Available options: * @param {Object} options Available options:
* {ItemSet} itemset
* {Number} margin
* {function} order Stacking order
* {Number} [margin.item=10]
* {Number} [margin.axis=20]
* {function} [order] Stacking order
*/ */
Stack.prototype.setOptions = function setOptions (options) { Stack.prototype.setOptions = function setOptions (options) {
util.extend(this.options, options); util.extend(this.options, options);
// TODO: register on data changes at the connected itemset, and update the changed part only and immediately
}; };
/** /**
* Stack the items such that they don't overlap. The items will have a minimal
* distance equal to options.margin.item.
* Order an array with items using a predefined order function for items
* @param {Item[]} items
*/ */
Stack.prototype.update = function update() {
this._order();
this._stack();
Stack.prototype.order = function order(items) {
//order the items
var order = this.options.order || this.defaultOptions.order;
if (!(typeof order === 'function')) {
throw new Error('Option order must be a function');
}
items.sort(order);
}; };
/** /**
* Order the items. If a custom order function has been provided via the options,
* then this will be used.
* @private
* Order items by their start data
* @param {Item[]} items
*/ */
Stack.prototype._order = function _order () {
var items = this.itemset.items;
if (!items) {
throw new Error('Cannot stack items: ItemSet does not contain items');
}
// TODO: store the sorted items, to have less work later on
var ordered = [];
var index = 0;
// items is a map (no array)
util.forEach(items, function (item) {
if (item.visible) {
ordered[index] = item;
index++;
}
Stack.prototype.orderByStart = function orderByStart(items) {
items.sort(function (a, b) {
return a.data.start - b.data.start;
}); });
};
//if a customer stack order function exists, use it.
var order = this.options.order || this.defaultOptions.order;
if (!(typeof order === 'function')) {
throw new Error('Option order must be a function');
}
ordered.sort(order);
/**
* Order items by their end date. If they have no end date, their start date
* is used.
* @param {Item[]} items
*/
Stack.prototype.orderByEnd = function orderByEnd(items) {
items.sort(function (a, b) {
var aTime = ('end' in a.data) ? a.data.end : a.data.start,
bTime = ('end' in b.data) ? b.data.end : b.data.start;
this.ordered = ordered;
return aTime - bTime;
});
}; };
/** /**
* Adjust vertical positions of the events such that they don't overlap each * Adjust vertical positions of the events such that they don't overlap each
* other. * other.
* @param {Item[]} items All visible items
* @param {boolean} [force=false] If true, all items will be re-stacked.
* If false (default), only items having a
* top===null will be re-stacked
* @private * @private
*/ */
Stack.prototype._stack = function _stack () {
Stack.prototype.stack = function stack (items, force) {
var i, var i,
iMax, iMax,
ordered = this.ordered,
options = this.options, options = this.options,
orientation = options.orientation || this.defaultOptions.orientation,
axisOnTop = (orientation == 'top'),
margin;
marginItem,
marginAxis;
if (options.margin && options.margin.item !== undefined) { if (options.margin && options.margin.item !== undefined) {
margin = options.margin.item;
marginItem = options.margin.item;
} }
else { else {
margin = this.defaultOptions.margin.item
marginItem = this.defaultOptions.margin.item
}
if (options.margin && options.margin.axis !== undefined) {
marginAxis = options.margin.axis;
}
else {
marginAxis = this.defaultOptions.margin.axis
} }
// calculate new, non-overlapping positions
for (i = 0, iMax = ordered.length; i < iMax; i++) {
var item = ordered[i];
var collidingItem = null;
do {
// TODO: optimize checking for overlap. when there is a gap without items,
// you only need to check for items from the next item on, not from zero
collidingItem = this.checkOverlap(ordered, i, 0, i - 1, margin);
if (collidingItem != null) {
// There is a collision. Reposition the event above the colliding element
if (axisOnTop) {
item.top = collidingItem.top + collidingItem.height + margin;
}
else {
item.top = collidingItem.top - item.height - margin;
}
}
} while (collidingItem);
if (force) {
// reset top position of all items
for (i = 0, iMax = items.length; i < iMax; i++) {
items[i].top = null;
}
} }
};
/**
* Check if the destiny position of given item overlaps with any
* of the other items from index itemStart to itemEnd.
* @param {Array} items Array with items
* @param {int} itemIndex Number of the item to be checked for overlap
* @param {int} itemStart First item to be checked.
* @param {int} itemEnd Last item to be checked.
* @return {Object | null} colliding item, or undefined when no collisions
* @param {Number} margin A minimum required margin.
* If margin is provided, the two items will be
* marked colliding when they overlap or
* when the margin between the two is smaller than
* the requested margin.
*/
Stack.prototype.checkOverlap = function checkOverlap (items, itemIndex,
itemStart, itemEnd, margin) {
var collision = this.collision;
// calculate new, non-overlapping positions
for (i = 0, iMax = items.length; i < iMax; i++) {
var item = items[i];
if (item.top === null) {
// initialize top position
item.top = marginAxis;
do {
// TODO: optimize checking for overlap. when there is a gap without items,
// you only need to check for items from the next item on, not from zero
var collidingItem = null;
for (var j = 0, jj = items.length; j < jj; j++) {
var other = items[j];
if (other.top !== null && other !== item && this.collision(item, other, marginItem)) {
collidingItem = other;
break;
}
}
// we loop from end to start, as we suppose that the chance of a
// collision is larger for items at the end, so check these first.
var a = items[itemIndex];
for (var i = itemEnd; i >= itemStart; i--) {
var b = items[i];
if (collision(a, b, margin)) {
if (i != itemIndex) {
return b;
}
if (collidingItem != null) {
// There is a collision. Reposition the event above the colliding element
item.top = collidingItem.top + collidingItem.height + marginItem;
}
} while (collidingItem);
} }
} }
return null;
}; };
/** /**

+ 312
- 188
src/timeline/Timeline.js View File

@ -6,10 +6,14 @@
* @constructor * @constructor
*/ */
function Timeline (container, items, options) { function Timeline (container, items, options) {
// validate arguments
if (!container) throw new Error('No container element provided');
var me = this; var me = this;
var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0); var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0);
this.options = { this.options = {
orientation: 'bottom', orientation: 'bottom',
direction: 'horizontal', // 'horizontal' or 'vertical'
autoResize: true, autoResize: true,
editable: false, editable: false,
selectable: true, selectable: true,
@ -27,6 +31,14 @@ function Timeline (container, items, options) {
showCurrentTime: false, showCurrentTime: false,
showCustomTime: false, showCustomTime: false,
type: 'box',
align: 'center',
margin: {
axis: 20,
item: 10
},
padding: 5,
onAdd: function (item, callback) { onAdd: function (item, callback) {
callback(item); callback(item);
}, },
@ -38,109 +50,194 @@ function Timeline (container, items, options) {
}, },
onRemove: function (item, callback) { onRemove: function (item, callback) {
callback(item); callback(item);
}
};
},
// controller
this.controller = new Controller();
toScreen: me._toScreen.bind(me),
toTime: me._toTime.bind(me)
};
// root panel // root panel
if (!container) {
throw new Error('No container element provided');
}
var rootOptions = Object.create(this.options);
rootOptions.height = function () {
// TODO: change to height
if (me.options.height) {
// fixed height
return me.options.height;
}
else {
// auto height
return (me.timeaxis.height + me.content.height) + 'px';
var rootOptions = util.extend(Object.create(this.options), {
height: function () {
if (me.options.height) {
// fixed height
return me.options.height;
}
else {
// auto height
// TODO: implement a css based solution to automatically have the right hight
return (me.timeAxis.height + me.contentPanel.height) + 'px';
}
} }
};
});
this.rootPanel = new RootPanel(container, rootOptions); this.rootPanel = new RootPanel(container, rootOptions);
this.controller.add(this.rootPanel);
// single select (or unselect) when tapping an item // single select (or unselect) when tapping an item
this.controller.on('tap', this._onSelectItem.bind(this));
this.rootPanel.on('tap', this._onSelectItem.bind(this));
// multi select when holding mouse/touch, or on ctrl+click // multi select when holding mouse/touch, or on ctrl+click
this.controller.on('hold', this._onMultiSelectItem.bind(this));
this.rootPanel.on('hold', this._onMultiSelectItem.bind(this));
// add item on doubletap // add item on doubletap
this.controller.on('doubletap', this._onAddItem.bind(this));
this.rootPanel.on('doubletap', this._onAddItem.bind(this));
// item panel
var itemOptions = Object.create(this.options);
itemOptions.left = function () {
return me.labelPanel.width;
};
itemOptions.width = function () {
return me.rootPanel.width - me.labelPanel.width;
};
itemOptions.top = null;
itemOptions.height = null;
this.itemPanel = new Panel(this.rootPanel, [], itemOptions);
this.controller.add(this.itemPanel);
// label panel
var labelOptions = Object.create(this.options);
labelOptions.top = null;
labelOptions.left = null;
labelOptions.height = null;
labelOptions.width = function () {
if (me.content && typeof me.content.getLabelsWidth === 'function') {
return me.content.getLabelsWidth();
}
else {
return 0;
// side panel
var sideOptions = util.extend(Object.create(this.options), {
top: function () {
return (sideOptions.orientation == 'top') ? '0' : '';
},
bottom: function () {
return (sideOptions.orientation == 'top') ? '' : '0';
},
left: '0',
right: null,
height: '100%',
width: function () {
if (me.groupSet) {
return me.groupSet.getLabelsWidth();
}
else {
return 0;
}
},
className: function () {
return 'side' + (me.groupsData ? '' : ' hidden');
} }
};
this.labelPanel = new Panel(this.rootPanel, [], labelOptions);
this.controller.add(this.labelPanel);
});
this.sidePanel = new Panel(sideOptions);
this.rootPanel.appendChild(this.sidePanel);
// main panel (contains time axis and itemsets)
var mainOptions = util.extend(Object.create(this.options), {
left: function () {
// we align left to enable a smooth resizing of the window
return me.sidePanel.width;
},
right: null,
height: '100%',
width: function () {
return me.rootPanel.width - me.sidePanel.width;
},
className: 'main'
});
this.mainPanel = new Panel(mainOptions);
this.rootPanel.appendChild(this.mainPanel);
// range // range
// TODO: move range inside rootPanel?
var rangeOptions = Object.create(this.options); var rangeOptions = Object.create(this.options);
this.range = new Range(rangeOptions);
this.range = new Range(this.rootPanel, this.mainPanel, rangeOptions);
this.range.setRange( this.range.setRange(
now.clone().add('days', -3).valueOf(), now.clone().add('days', -3).valueOf(),
now.clone().add('days', 4).valueOf() now.clone().add('days', 4).valueOf()
); );
this.range.subscribe(this.controller, this.rootPanel, 'move', 'horizontal');
this.range.subscribe(this.controller, this.rootPanel, 'zoom', 'horizontal');
this.range.on('rangechange', function (properties) { this.range.on('rangechange', function (properties) {
var force = true;
me.controller.emit('rangechange', properties);
me.controller.emit('request-reflow', force);
me.rootPanel.repaint();
me.emit('rangechange', properties);
}); });
this.range.on('rangechanged', function (properties) { this.range.on('rangechanged', function (properties) {
var force = true;
me.controller.emit('rangechanged', properties);
me.controller.emit('request-reflow', force);
me.rootPanel.repaint();
me.emit('rangechanged', properties);
});
// panel with time axis
var timeAxisOptions = util.extend(Object.create(rootOptions), {
range: this.range,
left: null,
top: null,
width: null,
height: null
});
this.timeAxis = new TimeAxis(timeAxisOptions);
this.timeAxis.setRange(this.range);
this.options.snap = this.timeAxis.snap.bind(this.timeAxis);
this.mainPanel.appendChild(this.timeAxis);
// content panel (contains itemset(s))
var contentOptions = util.extend(Object.create(this.options), {
top: function () {
return (me.options.orientation == 'top') ? (me.timeAxis.height + 'px') : '';
},
bottom: function () {
return (me.options.orientation == 'top') ? '' : (me.timeAxis.height + 'px');
},
left: null,
right: null,
height: null,
width: null,
className: 'content'
});
this.contentPanel = new Panel(contentOptions);
this.mainPanel.appendChild(this.contentPanel);
// content panel (contains the vertical lines of box items)
var backgroundOptions = util.extend(Object.create(this.options), {
top: function () {
return (me.options.orientation == 'top') ? (me.timeAxis.height + 'px') : '';
},
bottom: function () {
return (me.options.orientation == 'top') ? '' : (me.timeAxis.height + 'px');
},
left: null,
right: null,
height: function () {
return me.contentPanel.height;
},
width: null,
className: 'background'
}); });
this.backgroundPanel = new Panel(backgroundOptions);
this.mainPanel.insertBefore(this.backgroundPanel, this.contentPanel);
// panel with axis holding the dots of item boxes
var axisPanelOptions = util.extend(Object.create(rootOptions), {
left: 0,
top: function () {
return (me.options.orientation == 'top') ? (me.timeAxis.height + 'px') : '';
},
bottom: function () {
return (me.options.orientation == 'top') ? '' : (me.timeAxis.height + 'px');
},
width: '100%',
height: 0,
className: 'axis'
});
this.axisPanel = new Panel(axisPanelOptions);
this.mainPanel.appendChild(this.axisPanel);
// time axis
var timeaxisOptions = Object.create(rootOptions);
timeaxisOptions.range = this.range;
timeaxisOptions.left = null;
timeaxisOptions.top = null;
timeaxisOptions.width = '100%';
timeaxisOptions.height = null;
this.timeaxis = new TimeAxis(this.itemPanel, [], timeaxisOptions);
this.timeaxis.setRange(this.range);
this.controller.add(this.timeaxis);
this.options.snap = this.timeaxis.snap.bind(this.timeaxis);
// content panel (contains itemset(s))
var sideContentOptions = util.extend(Object.create(this.options), {
top: function () {
return (me.options.orientation == 'top') ? (me.timeAxis.height + 'px') : '';
},
bottom: function () {
return (me.options.orientation == 'top') ? '' : (me.timeAxis.height + 'px');
},
left: null,
right: null,
height: null,
width: null,
className: 'side-content'
});
this.sideContentPanel = new Panel(sideContentOptions);
this.sidePanel.appendChild(this.sideContentPanel);
// current time bar // current time bar
this.currenttime = new CurrentTime(this.timeaxis, [], rootOptions);
this.controller.add(this.currenttime);
// Note: time bar will be attached in this.setOptions when selected
this.currentTime = new CurrentTime(this.range, rootOptions);
// custom time bar // custom time bar
this.customtime = new CustomTime(this.timeaxis, [], rootOptions);
this.controller.add(this.customtime);
// Note: time bar will be attached in this.setOptions when selected
this.customTime = new CustomTime(rootOptions);
this.customTime.on('timechange', function (time) {
me.emit('timechange', time);
});
this.customTime.on('timechanged', function (time) {
me.emit('timechanged', time);
});
this.itemSet = null;
this.groupSet = null;
// create groupset // create groupset
this.setGroups(null); this.setGroups(null);
@ -159,24 +256,8 @@ function Timeline (container, items, options) {
} }
} }
/**
* Add an event listener to the timeline
* @param {String} event Available events: select, rangechange, rangechanged,
* timechange, timechanged
* @param {function} callback
*/
Timeline.prototype.on = function on (event, callback) {
this.controller.on(event, callback);
};
/**
* Add an event listener from the timeline
* @param {String} event
* @param {function} callback
*/
Timeline.prototype.off = function off (event, callback) {
this.controller.off(event, callback);
};
// turn Timeline into an event emitter
Emitter(Timeline.prototype);
/** /**
* Set options * Set options
@ -208,8 +289,39 @@ Timeline.prototype.setOptions = function (options) {
}).bind(this); }).bind(this);
['onAdd', 'onUpdate', 'onRemove', 'onMove'].forEach(validateCallback); ['onAdd', 'onUpdate', 'onRemove', 'onMove'].forEach(validateCallback);
this.controller.reflow();
this.controller.repaint();
// add/remove the current time bar
if (this.options.showCurrentTime) {
if (!this.mainPanel.hasChild(this.currentTime)) {
this.mainPanel.appendChild(this.currentTime);
this.currentTime.start();
}
}
else {
if (this.mainPanel.hasChild(this.currentTime)) {
this.currentTime.stop();
this.mainPanel.removeChild(this.currentTime);
}
}
// add/remove the custom time bar
if (this.options.showCustomTime) {
if (!this.mainPanel.hasChild(this.customTime)) {
this.mainPanel.appendChild(this.customTime);
}
}
else {
if (this.mainPanel.hasChild(this.customTime)) {
this.mainPanel.removeChild(this.customTime);
}
}
// TODO: remove deprecation error one day (deprecated since version 0.8.0)
if (options && options.order) {
throw new Error('Option order is deprecated. There is no replacement for this feature.');
}
// repaint everything
this.rootPanel.repaint();
}; };
/** /**
@ -217,11 +329,11 @@ Timeline.prototype.setOptions = function (options) {
* @param {Date} time * @param {Date} time
*/ */
Timeline.prototype.setCustomTime = function (time) { Timeline.prototype.setCustomTime = function (time) {
if (!this.customtime) {
if (!this.customTime) {
throw new Error('Cannot get custom time: Custom time bar is not enabled'); throw new Error('Cannot get custom time: Custom time bar is not enabled');
} }
this.customtime.setCustomTime(time);
this.customTime.setCustomTime(time);
}; };
/** /**
@ -229,11 +341,11 @@ Timeline.prototype.setCustomTime = function (time) {
* @return {Date} customTime * @return {Date} customTime
*/ */
Timeline.prototype.getCustomTime = function() { Timeline.prototype.getCustomTime = function() {
if (!this.customtime) {
if (!this.customTime) {
throw new Error('Cannot get custom time: Custom time bar is not enabled'); throw new Error('Cannot get custom time: Custom time bar is not enabled');
} }
return this.customtime.getCustomTime();
return this.customTime.getCustomTime();
}; };
/** /**
@ -263,7 +375,7 @@ Timeline.prototype.setItems = function(items) {
// set items // set items
this.itemsData = newDataSet; this.itemsData = newDataSet;
this.content.setItems(newDataSet);
(this.itemSet || this.groupSet).setItems(newDataSet);
if (initialLoad && (this.options.start == undefined || this.options.end == undefined)) { if (initialLoad && (this.options.start == undefined || this.options.end == undefined)) {
// apply the data range as range // apply the data range as range
@ -290,87 +402,84 @@ Timeline.prototype.setItems = function(items) {
end = util.convert(this.options.end, 'Date'); end = util.convert(this.options.end, 'Date');
} }
// apply range if there is a min or max available
if (start != null || end != null) {
this.range.setRange(start, end);
// skip range set if there is no start and end date
if (start === null && end === null) {
return;
} }
// if start and end dates are set but cannot be satisfyed due to zoom restrictions — correct end date
if (start != null && end != null) {
var diff = end.valueOf() - start.valueOf();
if (this.options.zoomMax != undefined && this.options.zoomMax < diff) {
end = new Date(start.valueOf() + this.options.zoomMax);
}
if (this.options.zoomMin != undefined && this.options.zoomMin > diff) {
end = new Date(start.valueOf() + this.options.zoomMin);
}
}
this.range.setRange(start, end);
} }
}; };
/** /**
* Set groups * Set groups
* @param {vis.DataSet | Array | google.visualization.DataTable} groups
* @param {vis.DataSet | Array | google.visualization.DataTable} groupSet
*/ */
Timeline.prototype.setGroups = function(groups) {
Timeline.prototype.setGroups = function(groupSet) {
var me = this; var me = this;
this.groupsData = groups;
// switch content type between ItemSet or GroupSet when needed
var Type = this.groupsData ? GroupSet : ItemSet;
if (!(this.content instanceof Type)) {
// remove old content set
if (this.content) {
this.content.hide();
if (this.content.setItems) {
this.content.setItems(); // disconnect from items
}
if (this.content.setGroups) {
this.content.setGroups(); // disconnect from groups
}
this.controller.remove(this.content);
}
this.groupsData = groupSet;
// create options for the itemset or groupset
var options = util.extend(Object.create(this.options), {
top: null,
bottom: null,
right: null,
left: null,
width: null,
height: null
});
// create new content set
var options = Object.create(this.options);
util.extend(options, {
top: function () {
if (me.options.orientation == 'top') {
return me.timeaxis.height;
}
else {
return me.itemPanel.height - me.timeaxis.height - me.content.height;
}
},
left: null,
width: '100%',
height: function () {
if (me.options.height) {
// fixed height
return me.itemPanel.height - me.timeaxis.height;
}
else {
// auto height
return null;
}
},
maxHeight: function () {
// TODO: change maxHeight to be a css string like '100%' or '300px'
if (me.options.maxHeight) {
if (!util.isNumber(me.options.maxHeight)) {
throw new TypeError('Number expected for property maxHeight');
}
return me.options.maxHeight - me.timeaxis.height;
}
else {
return null;
}
},
labelContainer: function () {
return me.labelPanel.getContainer();
}
});
if (this.groupsData) {
// Create a GroupSet
this.content = new Type(this.itemPanel, [this.timeaxis], options);
if (this.content.setRange) {
this.content.setRange(this.range);
// remove itemset if existing
if (this.itemSet) {
this.itemSet.hide(); // TODO: not so nice having to hide here
this.contentPanel.removeChild(this.itemSet);
this.itemSet.setItems(); // disconnect from itemset
this.itemSet = null;
} }
if (this.content.setItems) {
this.content.setItems(this.itemsData);
// create new GroupSet when needed
if (!this.groupSet) {
this.groupSet = new GroupSet(this.contentPanel, this.sideContentPanel, this.backgroundPanel, this.axisPanel, options);
this.groupSet.on('change', this.rootPanel.repaint.bind(this.rootPanel));
this.groupSet.setRange(this.range);
this.groupSet.setItems(this.itemsData);
this.groupSet.setGroups(this.groupsData);
this.contentPanel.appendChild(this.groupSet);
} }
if (this.content.setGroups) {
this.content.setGroups(this.groupsData);
else {
this.groupSet.setGroups(this.groupsData);
} }
this.controller.add(this.content);
}
else {
// ItemSet
if (this.groupSet) {
this.groupSet.hide(); // TODO: not so nice having to hide here
//this.groupSet.setGroups(); // disconnect from groupset
this.groupSet.setItems(); // disconnect from itemset
this.contentPanel.removeChild(this.groupSet);
this.groupSet = null;
}
// create new items
this.itemSet = new ItemSet(this.backgroundPanel, this.axisPanel, options);
this.itemSet.setRange(this.range);
this.itemSet.setItems(this.itemsData);
this.itemSet.on('change', me.rootPanel.repaint.bind(me.rootPanel));
this.contentPanel.appendChild(this.itemSet);
} }
}; };
@ -421,7 +530,9 @@ Timeline.prototype.getItemRange = function getItemRange() {
* unselected. * unselected.
*/ */
Timeline.prototype.setSelection = function setSelection (ids) { Timeline.prototype.setSelection = function setSelection (ids) {
if (this.content) this.content.setSelection(ids);
var itemOrGroupSet = (this.itemSet || this.groupSet);
if (itemOrGroupSet) itemOrGroupSet.setSelection(ids);
}; };
/** /**
@ -429,17 +540,32 @@ Timeline.prototype.setSelection = function setSelection (ids) {
* @return {Array} ids The ids of the selected items * @return {Array} ids The ids of the selected items
*/ */
Timeline.prototype.getSelection = function getSelection() { Timeline.prototype.getSelection = function getSelection() {
return this.content ? this.content.getSelection() : [];
var itemOrGroupSet = (this.itemSet || this.groupSet);
return itemOrGroupSet ? itemOrGroupSet.getSelection() : [];
}; };
/** /**
* Set the visible window. Both parameters are optional, you can change only * Set the visible window. Both parameters are optional, you can change only
* start or only end.
* start or only end. Syntax:
*
* TimeLine.setWindow(start, end)
* TimeLine.setWindow(range)
*
* Where start and end can be a Date, number, or string, and range is an
* object with properties start and end.
*
* @param {Date | Number | String} [start] Start date of visible window * @param {Date | Number | String} [start] Start date of visible window
* @param {Date | Number | String} [end] End date of visible window * @param {Date | Number | String} [end] End date of visible window
*/ */
Timeline.prototype.setWindow = function setWindow(start, end) { Timeline.prototype.setWindow = function setWindow(start, end) {
this.range.setRange(start, end);
if (arguments.length == 1) {
var range = arguments[0];
this.range.setRange(range.start, range.end);
}
else {
this.range.setRange(start, end);
}
}; };
/** /**
@ -470,14 +596,20 @@ Timeline.prototype._onSelectItem = function (event) {
return; return;
} }
var item = ItemSet.itemFromTarget(event);
var oldSelection = this.getSelection();
var item = ItemSet.itemFromTarget(event);
var selection = item ? [item.id] : []; var selection = item ? [item.id] : [];
this.setSelection(selection); this.setSelection(selection);
this.controller.emit('select', {
items: this.getSelection()
});
var newSelection = this.getSelection();
// if selection is changed, emit a select event
if (!util.equalArray(oldSelection, newSelection)) {
this.emit('select', {
items: this.getSelection()
});
}
event.stopPropagation(); event.stopPropagation();
}; };
@ -510,7 +642,7 @@ Timeline.prototype._onAddItem = function (event) {
var xAbs = vis.util.getAbsoluteLeft(this.rootPanel.frame); var xAbs = vis.util.getAbsoluteLeft(this.rootPanel.frame);
var x = event.gesture.center.pageX - xAbs; var x = event.gesture.center.pageX - xAbs;
var newItem = { var newItem = {
start: this.timeaxis.snap(this._toTime(x)),
start: this.timeAxis.snap(this._toTime(x)),
content: 'new item' content: 'new item'
}; };
@ -526,15 +658,7 @@ Timeline.prototype._onAddItem = function (event) {
this.options.onAdd(newItem, function (item) { this.options.onAdd(newItem, function (item) {
if (item) { if (item) {
me.itemsData.add(newItem); me.itemsData.add(newItem);
// select the created item after it is repainted
me.controller.once('repaint', function () {
me.setSelection([id]);
me.controller.emit('select', {
items: me.getSelection()
});
}.bind(me));
// TODO: need to trigger a repaint?
} }
}); });
} }
@ -566,7 +690,7 @@ Timeline.prototype._onMultiSelectItem = function (event) {
} }
this.setSelection(selection); this.setSelection(selection);
this.controller.emit('select', {
this.emit('select', {
items: this.getSelection() items: this.getSelection()
}); });
@ -581,7 +705,7 @@ Timeline.prototype._onMultiSelectItem = function (event) {
* @private * @private
*/ */
Timeline.prototype._toTime = function _toTime(x) { Timeline.prototype._toTime = function _toTime(x) {
var conversion = this.range.conversion(this.content.width);
var conversion = this.range.conversion(this.mainPanel.width);
return new Date(x / conversion.scale + conversion.offset); return new Date(x / conversion.scale + conversion.offset);
}; };
@ -593,6 +717,6 @@ Timeline.prototype._toTime = function _toTime(x) {
* @private * @private
*/ */
Timeline.prototype._toScreen = function _toScreen(time) { Timeline.prototype._toScreen = function _toScreen(time) {
var conversion = this.range.conversion(this.content.width);
var conversion = this.range.conversion(this.mainPanel.width);
return (time.valueOf() - conversion.offset) * conversion.scale; return (time.valueOf() - conversion.offset) * conversion.scale;
}; };

+ 17
- 95
src/timeline/component/Component.js View File

@ -4,17 +4,18 @@
function Component () { function Component () {
this.id = null; this.id = null;
this.parent = null; this.parent = null;
this.depends = null;
this.controller = null;
this.childs = null;
this.options = null; this.options = null;
this.frame = null; // main DOM element
this.top = 0; this.top = 0;
this.left = 0; this.left = 0;
this.width = 0; this.width = 0;
this.height = 0; this.height = 0;
} }
// Turn the Component into an event emitter
Emitter(Component.prototype);
/** /**
* Set parameters for the frame. Parameters will be merged in current parameter * Set parameters for the frame. Parameters will be merged in current parameter
* set. * set.
@ -29,10 +30,7 @@ Component.prototype.setOptions = function setOptions(options) {
if (options) { if (options) {
util.extend(this.options, options); util.extend(this.options, options);
if (this.controller) {
this.requestRepaint();
this.requestReflow();
}
this.repaint();
} }
}; };
@ -54,46 +52,18 @@ Component.prototype.getOption = function getOption(name) {
return value; return value;
}; };
/**
* Set controller for this component, or remove current controller by passing
* null as parameter value.
* @param {Controller | null} controller
*/
Component.prototype.setController = function setController (controller) {
this.controller = controller || null;
};
/**
* Get controller of this component
* @return {Controller} controller
*/
Component.prototype.getController = function getController () {
return this.controller;
};
/**
* Get the container element of the component, which can be used by a child to
* add its own widgets. Not all components do have a container for childs, in
* that case null is returned.
* @returns {HTMLElement | null} container
*/
// TODO: get rid of the getContainer and getFrame methods, provide these via the options
Component.prototype.getContainer = function getContainer() {
// should be implemented by the component
return null;
};
/** /**
* Get the frame element of the component, the outer HTML DOM element. * Get the frame element of the component, the outer HTML DOM element.
* @returns {HTMLElement | null} frame * @returns {HTMLElement | null} frame
*/ */
Component.prototype.getFrame = function getFrame() { Component.prototype.getFrame = function getFrame() {
return this.frame;
// should be implemented by the component
return null;
}; };
/** /**
* Repaint the component * Repaint the component
* @return {Boolean} changed
* @return {boolean} Returns true if the component is resized
*/ */
Component.prototype.repaint = function repaint() { Component.prototype.repaint = function repaint() {
// should be implemented by the component // should be implemented by the component
@ -101,64 +71,16 @@ Component.prototype.repaint = function repaint() {
}; };
/** /**
* Reflow the component
* @return {Boolean} resized
* Test whether the component is resized since the last time _isResized() was
* called.
* @return {Boolean} Returns true if the component is resized
* @private
*/ */
Component.prototype.reflow = function reflow() {
// should be implemented by the component
return false;
};
Component.prototype._isResized = function _isResized() {
var resized = (this._previousWidth !== this.width || this._previousHeight !== this.height);
/**
* Hide the component from the DOM
* @return {Boolean} changed
*/
Component.prototype.hide = function hide() {
if (this.frame && this.frame.parentNode) {
this.frame.parentNode.removeChild(this.frame);
return true;
}
else {
return false;
}
};
/**
* Show the component in the DOM (when not already visible).
* A repaint will be executed when the component is not visible
* @return {Boolean} changed
*/
Component.prototype.show = function show() {
if (!this.frame || !this.frame.parentNode) {
return this.repaint();
}
else {
return false;
}
};
this._previousWidth = this.width;
this._previousHeight = this.height;
/**
* Request a repaint. The controller will schedule a repaint
*/
Component.prototype.requestRepaint = function requestRepaint() {
if (this.controller) {
this.controller.emit('request-repaint');
}
else {
throw new Error('Cannot request a repaint: no controller configured');
// TODO: just do a repaint when no parent is configured?
}
};
/**
* Request a reflow. The controller will schedule a reflow
*/
Component.prototype.requestReflow = function requestReflow() {
if (this.controller) {
this.controller.emit('request-reflow');
}
else {
throw new Error('Cannot request a reflow: no controller configured');
// TODO: just do a reflow when no parent is configured?
}
return resized;
}; };

+ 0
- 113
src/timeline/component/ContentPanel.js View File

@ -1,113 +0,0 @@
/**
* A content panel can contain a groupset or an itemset, and can handle
* vertical scrolling
* @param {Component} [parent]
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] Available parameters:
* {String | Number | function} [left]
* {String | Number | function} [top]
* {String | Number | function} [width]
* {String | Number | function} [height]
* {String | function} [className]
* @constructor ContentPanel
* @extends Panel
*/
function ContentPanel(parent, depends, options) {
this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.options = options || {};
}
ContentPanel.prototype = new Component();
/**
* Set options. Will extend the current options.
* @param {Object} [options] Available parameters:
* {String | function} [className]
* {String | Number | function} [left]
* {String | Number | function} [top]
* {String | Number | function} [width]
* {String | Number | function} [height]
*/
ContentPanel.prototype.setOptions = Component.prototype.setOptions;
/**
* Get the container element of the panel, which can be used by a child to
* add its own widgets.
* @returns {HTMLElement} container
*/
ContentPanel.prototype.getContainer = function () {
return this.frame;
};
/**
* Repaint the component
* @return {Boolean} changed
*/
ContentPanel.prototype.repaint = function () {
var changed = 0,
update = util.updateProperty,
asSize = util.option.asSize,
options = this.options,
frame = this.frame;
if (!frame) {
frame = document.createElement('div');
frame.className = 'content-panel';
var className = options.className;
if (className) {
if (typeof className == 'function') {
util.addClassName(frame, String(className()));
}
else {
util.addClassName(frame, String(className));
}
}
this.frame = frame;
changed += 1;
}
if (!frame.parentNode) {
if (!this.parent) {
throw new Error('Cannot repaint panel: no parent attached');
}
var parentContainer = this.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint panel: parent has no container element');
}
parentContainer.appendChild(frame);
changed += 1;
}
changed += update(frame.style, 'top', asSize(options.top, '0px'));
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
changed += update(frame.style, 'height', asSize(options.height, '100%'));
return (changed > 0);
};
/**
* Reflow the component
* @return {Boolean} resized
*/
ContentPanel.prototype.reflow = function () {
var changed = 0,
update = util.updateProperty,
frame = this.frame;
if (frame) {
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
changed += update(this, 'width', frame.offsetWidth);
changed += update(this, 'height', frame.offsetHeight);
}
else {
changed += 1;
}
return (changed > 0);
};

+ 54
- 59
src/timeline/component/CurrentTime.js View File

@ -1,23 +1,22 @@
/** /**
* A current time bar * A current time bar
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Range} range
* @param {Object} [options] Available parameters: * @param {Object} [options] Available parameters:
* {Boolean} [showCurrentTime] * {Boolean} [showCurrentTime]
* @constructor CurrentTime * @constructor CurrentTime
* @extends Component * @extends Component
*/ */
function CurrentTime (parent, depends, options) {
function CurrentTime (range, options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.range = range;
this.options = options || {}; this.options = options || {};
this.defaultOptions = { this.defaultOptions = {
showCurrentTime: false showCurrentTime: false
}; };
this._create();
} }
CurrentTime.prototype = new Component(); CurrentTime.prototype = new Component();
@ -25,77 +24,73 @@ CurrentTime.prototype = new Component();
CurrentTime.prototype.setOptions = Component.prototype.setOptions; CurrentTime.prototype.setOptions = Component.prototype.setOptions;
/** /**
* Get the container element of the bar, which can be used by a child to
* add its own widgets.
* @returns {HTMLElement} container
* Create the HTML DOM for the current time bar
* @private
*/ */
CurrentTime.prototype.getContainer = function () {
return this.frame;
CurrentTime.prototype._create = function _create () {
var bar = document.createElement('div');
bar.className = 'currenttime';
bar.style.position = 'absolute';
bar.style.top = '0px';
bar.style.height = '100%';
this.bar = bar;
};
/**
* Get the frame element of the current time bar
* @returns {HTMLElement} frame
*/
CurrentTime.prototype.getFrame = function getFrame() {
return this.bar;
}; };
/** /**
* Repaint the component * Repaint the component
* @return {Boolean} changed
* @return {boolean} Returns true if the component is resized
*/ */
CurrentTime.prototype.repaint = function () {
var bar = this.frame,
parent = this.parent,
parentContainer = parent.parent.getContainer();
CurrentTime.prototype.repaint = function repaint() {
var parent = this.parent;
if (!parent) {
throw new Error('Cannot repaint bar: no parent attached');
}
var now = new Date();
var x = this.options.toScreen(now);
if (!parentContainer) {
throw new Error('Cannot repaint bar: parent has no container element');
}
this.bar.style.left = x + 'px';
this.bar.title = 'Current time: ' + now;
if (!this.getOption('showCurrentTime')) {
if (bar) {
parentContainer.removeChild(bar);
delete this.frame;
}
return false;
};
return false;
}
/**
* Start auto refreshing the current time bar
*/
CurrentTime.prototype.start = function start() {
var me = this;
if (!bar) {
bar = document.createElement('div');
bar.className = 'currenttime';
bar.style.position = 'absolute';
bar.style.top = '0px';
bar.style.height = '100%';
function update () {
me.stop();
parentContainer.appendChild(bar);
this.frame = bar;
}
// determine interval to refresh
var scale = me.range.conversion(me.parent.width).scale;
var interval = 1 / scale / 10;
if (interval < 30) interval = 30;
if (interval > 1000) interval = 1000;
if (!parent.conversion) {
parent._updateConversion();
}
me.repaint();
var now = new Date();
var x = parent.toScreen(now);
// start a timer to adjust for the new time
me.currentTimeTimer = setTimeout(update, interval);
}
bar.style.left = x + 'px';
bar.title = 'Current time: ' + now;
update();
};
// start a timer to adjust for the new time
/**
* Stop auto refreshing the current time bar
*/
CurrentTime.prototype.stop = function stop() {
if (this.currentTimeTimer !== undefined) { if (this.currentTimeTimer !== undefined) {
clearTimeout(this.currentTimeTimer); clearTimeout(this.currentTimeTimer);
delete this.currentTimeTimer; delete this.currentTimeTimer;
} }
var timeline = this;
var interval = 1 / parent.conversion.scale / 2;
if (interval < 30) {
interval = 30;
}
this.currentTimeTimer = setTimeout(function() {
timeline.repaint();
}, interval);
return false;
}; };

+ 55
- 83
src/timeline/component/CustomTime.js View File

@ -1,18 +1,13 @@
/** /**
* A custom time bar * A custom time bar
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] Available parameters: * @param {Object} [options] Available parameters:
* {Boolean} [showCustomTime] * {Boolean} [showCustomTime]
* @constructor CustomTime * @constructor CustomTime
* @extends Component * @extends Component
*/ */
function CustomTime (parent, depends, options) {
function CustomTime (options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.options = options || {}; this.options = options || {};
this.defaultOptions = { this.defaultOptions = {
@ -21,85 +16,61 @@ function CustomTime (parent, depends, options) {
this.customTime = new Date(); this.customTime = new Date();
this.eventParams = {}; // stores state parameters while dragging the bar this.eventParams = {}; // stores state parameters while dragging the bar
// create the DOM
this._create();
} }
CustomTime.prototype = new Component(); CustomTime.prototype = new Component();
Emitter(CustomTime.prototype);
CustomTime.prototype.setOptions = Component.prototype.setOptions; CustomTime.prototype.setOptions = Component.prototype.setOptions;
/** /**
* Get the container element of the bar, which can be used by a child to
* add its own widgets.
* @returns {HTMLElement} container
* Create the DOM for the custom time
* @private
*/
CustomTime.prototype._create = function _create () {
var bar = document.createElement('div');
bar.className = 'customtime';
bar.style.position = 'absolute';
bar.style.top = '0px';
bar.style.height = '100%';
this.bar = bar;
var drag = document.createElement('div');
drag.style.position = 'relative';
drag.style.top = '0px';
drag.style.left = '-10px';
drag.style.height = '100%';
drag.style.width = '20px';
bar.appendChild(drag);
// attach event listeners
this.hammer = Hammer(bar, {
prevent_default: true
});
this.hammer.on('dragstart', this._onDragStart.bind(this));
this.hammer.on('drag', this._onDrag.bind(this));
this.hammer.on('dragend', this._onDragEnd.bind(this));
};
/**
* Get the frame element of the custom time bar
* @returns {HTMLElement} frame
*/ */
CustomTime.prototype.getContainer = function () {
return this.frame;
CustomTime.prototype.getFrame = function getFrame() {
return this.bar;
}; };
/** /**
* Repaint the component * Repaint the component
* @return {Boolean} changed
* @return {boolean} Returns true if the component is resized
*/ */
CustomTime.prototype.repaint = function () { CustomTime.prototype.repaint = function () {
var bar = this.frame,
parent = this.parent;
if (!parent) {
throw new Error('Cannot repaint bar: no parent attached');
}
var parentContainer = parent.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint bar: parent has no container element');
}
if (!this.getOption('showCustomTime')) {
if (bar) {
parentContainer.removeChild(bar);
delete this.frame;
}
return false;
}
if (!bar) {
bar = document.createElement('div');
bar.className = 'customtime';
bar.style.position = 'absolute';
bar.style.top = '0px';
bar.style.height = '100%';
parentContainer.appendChild(bar);
var drag = document.createElement('div');
drag.style.position = 'relative';
drag.style.top = '0px';
drag.style.left = '-10px';
drag.style.height = '100%';
drag.style.width = '20px';
bar.appendChild(drag);
this.frame = bar;
// attach event listeners
this.hammer = Hammer(bar, {
prevent_default: true
});
this.hammer.on('dragstart', this._onDragStart.bind(this));
this.hammer.on('drag', this._onDrag.bind(this));
this.hammer.on('dragend', this._onDragEnd.bind(this));
}
if (!parent.conversion) {
parent._updateConversion();
}
var x = parent.toScreen(this.customTime);
bar.style.left = x + 'px';
bar.title = 'Time: ' + this.customTime;
var x = this.options.toScreen(this.customTime);
this.bar.style.left = x + 'px';
this.bar.title = 'Time: ' + this.customTime;
return false; return false;
}; };
@ -127,6 +98,7 @@ CustomTime.prototype.getCustomTime = function() {
* @private * @private
*/ */
CustomTime.prototype._onDragStart = function(event) { CustomTime.prototype._onDragStart = function(event) {
this.eventParams.dragging = true;
this.eventParams.customTime = this.customTime; this.eventParams.customTime = this.customTime;
event.stopPropagation(); event.stopPropagation();
@ -139,18 +111,18 @@ CustomTime.prototype._onDragStart = function(event) {
* @private * @private
*/ */
CustomTime.prototype._onDrag = function (event) { CustomTime.prototype._onDrag = function (event) {
if (!this.eventParams.dragging) return;
var deltaX = event.gesture.deltaX, var deltaX = event.gesture.deltaX,
x = this.parent.toScreen(this.eventParams.customTime) + deltaX,
time = this.parent.toTime(x);
x = this.options.toScreen(this.eventParams.customTime) + deltaX,
time = this.options.toTime(x);
this.setCustomTime(time); this.setCustomTime(time);
// fire a timechange event // fire a timechange event
if (this.controller) {
this.controller.emit('timechange', {
time: this.customTime
})
}
this.emit('timechange', {
time: new Date(this.customTime.valueOf())
});
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
@ -162,12 +134,12 @@ CustomTime.prototype._onDrag = function (event) {
* @private * @private
*/ */
CustomTime.prototype._onDragEnd = function (event) { CustomTime.prototype._onDragEnd = function (event) {
if (!this.eventParams.dragging) return;
// fire a timechanged event // fire a timechanged event
if (this.controller) {
this.controller.emit('timechanged', {
time: this.customTime
})
}
this.emit('timechanged', {
time: new Date(this.customTime.valueOf())
});
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();

+ 135
- 51
src/timeline/component/Group.js View File

@ -1,17 +1,23 @@
/** /**
* @constructor Group * @constructor Group
* @param {GroupSet} parent
* @param {Panel} groupPanel
* @param {Panel} labelPanel
* @param {Panel} backgroundPanel
* @param {Panel} axisPanel
* @param {Number | String} groupId * @param {Number | String} groupId
* @param {Object} [options] Options to set initial property values * @param {Object} [options] Options to set initial property values
* // TODO: describe available options * // TODO: describe available options
* @extends Component * @extends Component
*/ */
function Group (parent, groupId, options) {
function Group (groupPanel, labelPanel, backgroundPanel, axisPanel, groupId, options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
this.parent = parent;
this.groupPanel = groupPanel;
this.labelPanel = labelPanel;
this.backgroundPanel = backgroundPanel;
this.axisPanel = axisPanel;
this.groupId = groupId; this.groupId = groupId;
this.itemset = null; // ItemSet
this.itemSet = null; // ItemSet
this.options = options || {}; this.options = options || {};
this.options.top = 0; this.options.top = 0;
@ -22,10 +28,14 @@ function Group (parent, groupId, options) {
} }
}; };
this.dom = {};
this.top = 0; this.top = 0;
this.left = 0; this.left = 0;
this.width = 0; this.width = 0;
this.height = 0; this.height = 0;
this._create();
} }
Group.prototype = new Component(); Group.prototype = new Component();
@ -34,47 +44,131 @@ Group.prototype = new Component();
Group.prototype.setOptions = Component.prototype.setOptions; Group.prototype.setOptions = Component.prototype.setOptions;
/** /**
* Get the container element of the panel, which can be used by a child to
* add its own widgets.
* @returns {HTMLElement} container
* Create DOM elements for the group
* @private
*/
Group.prototype._create = function() {
var label = document.createElement('div');
label.className = 'vlabel';
this.dom.label = label;
var inner = document.createElement('div');
inner.className = 'inner';
label.appendChild(inner);
this.dom.inner = inner;
};
/**
* Set the group data for this group
* @param {Object} data Group data, can contain properties content and className
*/ */
Group.prototype.getContainer = function () {
return this.parent.getContainer();
Group.prototype.setData = function setData(data) {
// update contents
var content = data && data.content;
if (content instanceof Element) {
this.dom.inner.appendChild(content);
}
else if (content != undefined) {
this.dom.inner.innerHTML = content;
}
else {
this.dom.inner.innerHTML = this.groupId;
}
// update className
var className = data && data.className;
if (className) {
util.addClassName(this.dom.label, className);
}
}; };
/** /**
* Set item set for the group. The group will create a view on the itemset,
* Set item set for the group. The group will create a view on the itemSet,
* filtered by the groups id. * filtered by the groups id.
* @param {DataSet | DataView} items
* @param {DataSet | DataView} itemsData
*/ */
Group.prototype.setItems = function setItems(items) {
if (this.itemset) {
Group.prototype.setItems = function setItems(itemsData) {
if (this.itemSet) {
// remove current item set // remove current item set
this.itemset.hide();
this.itemset.setItems();
this.parent.controller.remove(this.itemset);
this.itemset = null;
this.itemSet.setItems();
this.itemSet.hide();
this.groupPanel.frame.removeChild(this.itemSet.getFrame());
this.itemSet = null;
} }
if (items) {
if (itemsData) {
var groupId = this.groupId; var groupId = this.groupId;
var itemsetOptions = Object.create(this.options);
this.itemset = new ItemSet(this, null, itemsetOptions);
this.itemset.setRange(this.parent.range);
var me = this;
var itemSetOptions = util.extend(this.options, {
height: function () {
// FIXME: setting height doesn't yet work
return Math.max(me.props.label.height, me.itemSet.height);
}
});
this.itemSet = new ItemSet(this.backgroundPanel, this.axisPanel, itemSetOptions);
this.itemSet.on('change', this.emit.bind(this, 'change')); // propagate change event
this.itemSet.parent = this;
this.groupPanel.frame.appendChild(this.itemSet.getFrame());
if (this.range) this.itemSet.setRange(this.range);
this.view = new DataView(items, {
this.view = new DataView(itemsData, {
filter: function (item) { filter: function (item) {
return item.group == groupId; return item.group == groupId;
} }
}); });
this.itemset.setItems(this.view);
this.itemSet.setItems(this.view);
}
};
/**
* hide the group, detach from DOM if needed
*/
Group.prototype.show = function show() {
if (!this.dom.label.parentNode) {
this.labelPanel.frame.appendChild(this.dom.label);
}
var itemSetFrame = this.itemSet && this.itemSet.getFrame();
if (itemSetFrame) {
if (itemSetFrame.parentNode) {
itemSetFrame.parentNode.removeChild(itemSetFrame);
}
this.groupPanel.frame.appendChild(itemSetFrame);
this.itemSet.show();
}
};
/**
* hide the group, detach from DOM if needed
*/
Group.prototype.hide = function hide() {
if (this.dom.label.parentNode) {
this.dom.label.parentNode.removeChild(this.dom.label);
}
if (this.itemSet) {
this.itemSet.hide();
}
this.parent.controller.add(this.itemset);
var itemSetFrame = this.itemset && this.itemSet.getFrame();
if (itemSetFrame && itemSetFrame.parentNode) {
itemSetFrame.parentNode.removeChild(itemSetFrame);
} }
}; };
/**
* Set range (start and end).
* @param {Range | Object} range A Range or an object containing start and end.
*/
Group.prototype.setRange = function (range) {
this.range = range;
if (this.itemSet) this.itemSet.setRange(range);
};
/** /**
* Set selected items by their id. Replaces the current selection. * Set selected items by their id. Replaces the current selection.
* Unknown id's are silently ignored. * Unknown id's are silently ignored.
@ -83,7 +177,7 @@ Group.prototype.setItems = function setItems(items) {
* unselected. * unselected.
*/ */
Group.prototype.setSelection = function setSelection(ids) { Group.prototype.setSelection = function setSelection(ids) {
if (this.itemset) this.itemset.setSelection(ids);
if (this.itemSet) this.itemSet.setSelection(ids);
}; };
/** /**
@ -91,39 +185,29 @@ Group.prototype.setSelection = function setSelection(ids) {
* @return {Array} ids The ids of the selected items * @return {Array} ids The ids of the selected items
*/ */
Group.prototype.getSelection = function getSelection() { Group.prototype.getSelection = function getSelection() {
return this.itemset ? this.itemset.getSelection() : [];
return this.itemSet ? this.itemSet.getSelection() : [];
}; };
/** /**
* Repaint the item
* @return {Boolean} changed
* Repaint the group
* @return {boolean} Returns true if the component is resized
*/ */
Group.prototype.repaint = function repaint() { Group.prototype.repaint = function repaint() {
return false;
};
var resized = false;
/**
* Reflow the item
* @return {Boolean} resized
*/
Group.prototype.reflow = function reflow() {
var changed = 0,
update = util.updateProperty;
this.show();
changed += update(this, 'top', this.itemset ? this.itemset.top : 0);
changed += update(this, 'height', this.itemset ? this.itemset.height : 0);
if (this.itemSet) {
resized = this.itemSet.repaint() || resized;
}
// TODO: reckon with the height of the group label
// calculate inner size of the label
resized = util.updateProperty(this.props.label, 'width', this.dom.inner.clientWidth) || resized;
resized = util.updateProperty(this.props.label, 'height', this.dom.inner.clientHeight) || resized;
if (this.label) {
var inner = this.label.firstChild;
changed += update(this.props.label, 'width', inner.clientWidth);
changed += update(this.props.label, 'height', inner.clientHeight);
}
else {
changed += update(this.props.label, 'width', 0);
changed += update(this.props.label, 'height', 0);
}
this.height = this.itemSet ? this.itemSet.height : 0;
this.dom.label.style.height = this.height + 'px';
return (changed > 0);
return resized;
}; };

+ 189
- 304
src/timeline/component/GroupSet.js View File

@ -1,18 +1,23 @@
/** /**
* An GroupSet holds a set of groups * An GroupSet holds a set of groups
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Panel} contentPanel Panel where the ItemSets will be created
* @param {Panel} labelPanel Panel where the labels will be created
* @param {Panel} backgroundPanel Panel where the vertical lines of box
* items are created
* @param {Panel} axisPanel Panel on the axis where the dots of box
* items will be created
* @param {Object} [options] See GroupSet.setOptions for the available * @param {Object} [options] See GroupSet.setOptions for the available
* options. * options.
* @constructor GroupSet * @constructor GroupSet
* @extends Panel * @extends Panel
*/ */
function GroupSet(parent, depends, options) {
function GroupSet(contentPanel, labelPanel, backgroundPanel, axisPanel, options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.contentPanel = contentPanel;
this.labelPanel = labelPanel;
this.backgroundPanel = backgroundPanel;
this.axisPanel = axisPanel;
this.options = options || {}; this.options = options || {};
this.range = null; // Range or Object {start: number, end: number} this.range = null; // Range or Object {start: number, end: number}
@ -20,6 +25,7 @@ function GroupSet(parent, depends, options) {
this.groupsData = null; // DataSet with groups this.groupsData = null; // DataSet with groups
this.groups = {}; // map with groups this.groups = {}; // map with groups
this.groupIds = []; // list with ordered group ids
this.dom = {}; this.dom = {};
this.props = { this.props = {
@ -28,10 +34,7 @@ function GroupSet(parent, depends, options) {
} }
}; };
// TODO: implement right orientation of the labels
// changes in groups are queued key/value map containing id/action
this.queue = {};
// TODO: implement right orientation of the labels (left/right)
var me = this; var me = this;
this.listeners = { this.listeners = {
@ -45,10 +48,40 @@ function GroupSet(parent, depends, options) {
me._onRemove(params.items); me._onRemove(params.items);
} }
}; };
// create HTML DOM
this._create();
} }
GroupSet.prototype = new Panel(); GroupSet.prototype = new Panel();
/**
* Create the HTML DOM elements for the GroupSet
* @private
*/
GroupSet.prototype._create = function _create () {
// TODO: reimplement groupSet DOM elements
var frame = document.createElement('div');
frame.className = 'groupset';
frame['timeline-groupset'] = this;
this.frame = frame;
this.labelSet = new Panel({
className: 'labelset',
width: '100%',
height: '100%'
});
this.labelPanel.appendChild(this.labelSet);
};
/**
* Get the frame element of component
* @returns {null} Get frame is not supported by GroupSet
*/
GroupSet.prototype.getFrame = function getFrame() {
return this.frame;
};
/** /**
* Set options for the GroupSet. Existing options will be extended/overwritten. * Set options for the GroupSet. Existing options will be extended/overwritten.
* @param {Object} [options] The following options are available: * @param {Object} [options] The following options are available:
@ -57,8 +90,18 @@ GroupSet.prototype = new Panel();
*/ */
GroupSet.prototype.setOptions = Component.prototype.setOptions; GroupSet.prototype.setOptions = Component.prototype.setOptions;
/**
* Set range (start and end).
* @param {Range | Object} range A Range or an object containing start and end.
*/
GroupSet.prototype.setRange = function (range) { GroupSet.prototype.setRange = function (range) {
// TODO: implement setRange
this.range = range;
for (var id in this.groups) {
if (this.groups.hasOwnProperty(id)) {
this.groups[id].setRange(range);
}
}
}; };
/** /**
@ -71,6 +114,7 @@ GroupSet.prototype.setItems = function setItems(items) {
for (var id in this.groups) { for (var id in this.groups) {
if (this.groups.hasOwnProperty(id)) { if (this.groups.hasOwnProperty(id)) {
var group = this.groups[id]; var group = this.groups[id];
// TODO: every group will emit a change event, causing a lot of unnecessary repaints. improve this.
group.setItems(items); group.setItems(items);
} }
} }
@ -139,6 +183,8 @@ GroupSet.prototype.setGroups = function setGroups(groups) {
ids = this.groupsData.getIds(); ids = this.groupsData.getIds();
this._onAdd(ids); this._onAdd(ids);
} }
this.emit('change');
}; };
/** /**
@ -192,313 +238,117 @@ GroupSet.prototype.getSelection = function getSelection() {
/** /**
* Repaint the component * Repaint the component
* @return {Boolean} changed
* @return {boolean} Returns true if the component was resized since previous repaint
*/ */
GroupSet.prototype.repaint = function repaint() { GroupSet.prototype.repaint = function repaint() {
var changed = 0,
i, id, group, label,
update = util.updateProperty,
var i, id, group,
asSize = util.option.asSize, asSize = util.option.asSize,
asElement = util.option.asElement,
asString = util.option.asString,
options = this.options, options = this.options,
frame = this.dom.frame,
labels = this.dom.labels,
labelSet = this.dom.labelSet;
// create frame
if (!this.parent) {
throw new Error('Cannot repaint groupset: no parent attached');
}
var parentContainer = this.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint groupset: parent has no container element');
}
if (!frame) {
frame = document.createElement('div');
frame.className = 'groupset';
frame['timeline-groupset'] = this;
this.dom.frame = frame;
var className = options.className;
if (className) {
util.addClassName(frame, util.option.asString(className));
}
changed += 1;
}
if (!frame.parentNode) {
parentContainer.appendChild(frame);
changed += 1;
}
// create labels
var labelContainer = asElement(options.labelContainer);
if (!labelContainer) {
throw new Error('Cannot repaint groupset: option "labelContainer" not defined');
}
if (!labels) {
labels = document.createElement('div');
labels.className = 'labels';
this.dom.labels = labels;
}
if (!labelSet) {
labelSet = document.createElement('div');
labelSet.className = 'label-set';
labels.appendChild(labelSet);
this.dom.labelSet = labelSet;
}
if (!labels.parentNode || labels.parentNode != labelContainer) {
if (labels.parentNode) {
labels.parentNode.removeChild(labels.parentNode);
}
labelContainer.appendChild(labels);
}
// reposition frame
changed += update(frame.style, 'height', asSize(options.height, this.height + 'px'));
changed += update(frame.style, 'top', asSize(options.top, '0px'));
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
// reposition labels
changed += update(labelSet.style, 'top', asSize(options.top, '0px'));
changed += update(labelSet.style, 'height', asSize(options.height, this.height + 'px'));
var me = this,
queue = this.queue,
groups = this.groups,
groupsData = this.groupsData;
// show/hide added/changed/removed groups
var ids = Object.keys(queue);
if (ids.length) {
ids.forEach(function (id) {
var action = queue[id];
var group = groups[id];
//noinspection FallthroughInSwitchStatementJS
switch (action) {
case 'add':
case 'update':
if (!group) {
var groupOptions = Object.create(me.options);
util.extend(groupOptions, {
height: null,
maxHeight: null
});
group = new Group(me, id, groupOptions);
group.setItems(me.itemsData); // attach items data
groups[id] = group;
me.controller.add(group);
}
// TODO: update group data
group.data = groupsData.get(id);
delete queue[id];
break;
case 'remove':
if (group) {
group.setItems(); // detach items data
delete groups[id];
me.controller.remove(group);
}
// update lists
delete queue[id];
break;
default:
console.log('Error: unknown action "' + action + '"');
}
});
// the groupset depends on each of the groups
//this.depends = this.groups; // TODO: gives a circular reference through the parent
// TODO: apply dependencies of the groupset
// update the top positions of the groups in the correct order
var orderedGroups = this.groupsData.getIds({
order: this.options.groupOrder
});
for (i = 0; i < orderedGroups.length; i++) {
(function (group, prevGroup) {
var top = 0;
if (prevGroup) {
top = function () {
// TODO: top must reckon with options.maxHeight
return prevGroup.top + prevGroup.height;
}
}
group.setOptions({
top: top
});
})(groups[orderedGroups[i]], groups[orderedGroups[i - 1]]);
}
// (re)create the labels
while (labelSet.firstChild) {
labelSet.removeChild(labelSet.firstChild);
}
for (i = 0; i < orderedGroups.length; i++) {
id = orderedGroups[i];
label = this._createLabel(id);
labelSet.appendChild(label);
}
orientation = this.getOption('orientation'),
frame = this.frame,
resized = false,
groups = this.groups;
changed++;
}
// repaint all groups in order
this.groupIds.forEach(function (id) {
var groupResized = groups[id].repaint();
resized = resized || groupResized;
});
// reposition the labels
// TODO: labels are not displayed correctly when orientation=='top'
// TODO: width of labelPanel is not immediately updated on a change in groups
// reposition the labels and calculate the maximum label width
var maxWidth = 0;
for (id in groups) { for (id in groups) {
if (groups.hasOwnProperty(id)) { if (groups.hasOwnProperty(id)) {
group = groups[id]; group = groups[id];
label = group.label;
if (label) {
label.style.top = group.top + 'px';
label.style.height = group.height + 'px';
}
maxWidth = Math.max(maxWidth, group.props.label.width);
} }
} }
resized = util.updateProperty(this.props.labels, 'width', maxWidth) || resized;
return (changed > 0);
};
// recalculate the height of the groupset, and recalculate top positions of the groups
var fixedHeight = (asSize(options.height) != null);
var height;
if (!fixedHeight) {
// height is not specified, calculate the sum of the height of all groups
height = 0;
/**
* Create a label for group with given id
* @param {Number} id
* @return {Element} label
* @private
*/
GroupSet.prototype._createLabel = function(id) {
var group = this.groups[id];
var label = document.createElement('div');
label.className = 'vlabel';
var inner = document.createElement('div');
inner.className = 'inner';
label.appendChild(inner);
var content = group.data && group.data.content;
if (content instanceof Element) {
inner.appendChild(content);
}
else if (content != undefined) {
inner.innerHTML = content;
this.groupIds.forEach(function (id) {
var group = groups[id];
group.top = height;
if (group.itemSet) group.itemSet.top = group.top; // TODO: this is an ugly hack
height += group.height;
});
} }
var className = group.data && group.data.className;
if (className) {
util.addClassName(label, className);
}
// update classname
frame.className = 'groupset' + (options.className ? (' ' + asString(options.className)) : '');
group.label = label; // TODO: not so nice, parking labels in the group this way!!!
// calculate actual size and position
this.top = frame.offsetTop;
this.left = frame.offsetLeft;
this.width = frame.offsetWidth;
this.height = height;
return label;
return resized;
}; };
/** /**
* Get container element
* @return {HTMLElement} container
* Update the groupIds. Requires a repaint afterwards
* @private
*/ */
GroupSet.prototype.getContainer = function getContainer() {
return this.dom.frame;
GroupSet.prototype._updateGroupIds = function () {
// reorder the groups
this.groupIds = this.groupsData.getIds({
order: this.options.groupOrder
});
// hide the groups now, they will be shown again in the next repaint
// in correct order
var groups = this.groups;
this.groupIds.forEach(function (id) {
groups[id].hide();
});
}; };
/** /**
* Get the width of the group labels * Get the width of the group labels
* @return {Number} width * @return {Number} width
*/ */
GroupSet.prototype.getLabelsWidth = function getContainer() {
GroupSet.prototype.getLabelsWidth = function getLabelsWidth() {
return this.props.labels.width; return this.props.labels.width;
}; };
/**
* Reflow the component
* @return {Boolean} resized
*/
GroupSet.prototype.reflow = function reflow() {
var changed = 0,
id, group,
options = this.options,
update = util.updateProperty,
asNumber = util.option.asNumber,
asSize = util.option.asSize,
frame = this.dom.frame;
if (frame) {
var maxHeight = asNumber(options.maxHeight);
var fixedHeight = (asSize(options.height) != null);
var height;
if (fixedHeight) {
height = frame.offsetHeight;
}
else {
// height is not specified, calculate the sum of the height of all groups
height = 0;
for (id in this.groups) {
if (this.groups.hasOwnProperty(id)) {
group = this.groups[id];
height += group.height;
}
}
}
if (maxHeight != null) {
height = Math.min(height, maxHeight);
}
changed += update(this, 'height', height);
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
changed += update(this, 'width', frame.offsetWidth);
}
// calculate the maximum width of the labels
var width = 0;
for (id in this.groups) {
if (this.groups.hasOwnProperty(id)) {
group = this.groups[id];
var labelWidth = group.props && group.props.label && group.props.label.width || 0;
width = Math.max(width, labelWidth);
}
}
changed += update(this.props.labels, 'width', width);
return (changed > 0);
};
/** /**
* Hide the component from the DOM * Hide the component from the DOM
* @return {Boolean} changed
*/ */
GroupSet.prototype.hide = function hide() { GroupSet.prototype.hide = function hide() {
if (this.dom.frame && this.dom.frame.parentNode) {
this.dom.frame.parentNode.removeChild(this.dom.frame);
return true;
}
else {
return false;
// hide labelset
this.labelPanel.removeChild(this.labelSet);
// hide each of the groups
for (var groupId in this.groups) {
if (this.groups.hasOwnProperty(groupId)) {
this.groups[groupId].hide();
}
} }
}; };
/** /**
* Show the component in the DOM (when not already visible). * Show the component in the DOM (when not already visible).
* A repaint will be executed when the component is not visible
* @return {Boolean} changed * @return {Boolean} changed
*/ */
GroupSet.prototype.show = function show() { GroupSet.prototype.show = function show() {
if (!this.dom.frame || !this.dom.frame.parentNode) {
return this.repaint();
// show label set
if (!this.labelPanel.hasChild(this.labelSet)) {
this.labelPanel.removeChild(this.labelSet);
} }
else {
return false;
// show each of the groups
for (var groupId in this.groups) {
if (this.groups.hasOwnProperty(groupId)) {
this.groups[groupId].show();
}
} }
}; };
@ -508,7 +358,7 @@ GroupSet.prototype.show = function show() {
* @private * @private
*/ */
GroupSet.prototype._onUpdate = function _onUpdate(ids) { GroupSet.prototype._onUpdate = function _onUpdate(ids) {
this._toQueue(ids, 'update');
this._onAdd(ids);
}; };
/** /**
@ -517,7 +367,31 @@ GroupSet.prototype._onUpdate = function _onUpdate(ids) {
* @private * @private
*/ */
GroupSet.prototype._onAdd = function _onAdd(ids) { GroupSet.prototype._onAdd = function _onAdd(ids) {
this._toQueue(ids, 'add');
var me = this;
ids.forEach(function (id) {
var group = me.groups[id];
if (!group) {
var groupOptions = Object.create(me.options);
util.extend(groupOptions, {
height: null
});
group = new Group(me, me.labelSet, me.backgroundPanel, me.axisPanel, id, groupOptions);
group.on('change', me.emit.bind(me, 'change')); // propagate change event
group.setRange(me.range);
group.setItems(me.itemsData); // attach items data
me.groups[id] = group;
group.parent = me;
}
// update group data
group.setData(me.groupsData.get(id));
});
this._updateGroupIds();
this.emit('change');
}; };
/** /**
@ -526,50 +400,61 @@ GroupSet.prototype._onAdd = function _onAdd(ids) {
* @private * @private
*/ */
GroupSet.prototype._onRemove = function _onRemove(ids) { GroupSet.prototype._onRemove = function _onRemove(ids) {
this._toQueue(ids, 'remove');
};
/**
* Put groups in the queue to be added/updated/remove
* @param {Number[]} ids
* @param {String} action can be 'add', 'update', 'remove'
*/
GroupSet.prototype._toQueue = function _toQueue(ids, action) {
var queue = this.queue;
var groups = this.groups;
ids.forEach(function (id) { ids.forEach(function (id) {
queue[id] = action;
var group = groups[id];
if (group) {
group.setItems(); // detach items data
group.hide(); // FIXME: for some reason when doing setItems after hide, setItems again makes the label visible
delete groups[id];
}
}); });
if (this.controller) {
//this.requestReflow();
this.requestRepaint();
}
this._updateGroupIds();
this.emit('change');
}; };
/** /**
* Find the Group from an event target:
* Find the GroupSet from an event target:
* searches for the attribute 'timeline-groupset' in the event target's element * searches for the attribute 'timeline-groupset' in the event target's element
* tree, then finds the right group in this groupset * tree, then finds the right group in this groupset
* @param {Event} event * @param {Event} event
* @return {Group | null} group * @return {Group | null} group
*/ */
GroupSet.groupFromTarget = function groupFromTarget (event) {
var groupset,
target = event.target;
GroupSet.groupSetFromTarget = function groupSetFromTarget (event) {
var target = event.target;
while (target) { while (target) {
if (target.hasOwnProperty('timeline-groupset')) { if (target.hasOwnProperty('timeline-groupset')) {
groupset = target['timeline-groupset'];
break;
return target['timeline-groupset'];
} }
target = target.parentNode; target = target.parentNode;
} }
if (groupset) {
for (var groupId in groupset.groups) {
if (groupset.groups.hasOwnProperty(groupId)) {
var group = groupset.groups[groupId];
if (group.itemset && ItemSet.itemSetFromTarget(event) == group.itemset) {
return null;
};
/**
* Find the Group from an event target:
* searches for the two elements having attributes 'timeline-groupset' and
* 'timeline-itemset' in the event target's element, then finds the right group.
* @param {Event} event
* @return {Group | null} group
*/
GroupSet.groupFromTarget = function groupFromTarget (event) {
// find the groupSet
var groupSet = GroupSet.groupSetFromTarget(event);
// find the ItemSet
var itemSet = ItemSet.itemSetFromTarget(event);
// find the right group
if (groupSet && itemSet) {
for (var groupId in groupSet.groups) {
if (groupSet.groups.hasOwnProperty(groupId)) {
var group = groupSet.groups[groupId];
if (group.itemSet == itemSet) {
return group; return group;
} }
} }

+ 287
- 389
src/timeline/component/ItemSet.js View File

@ -2,41 +2,24 @@
* An ItemSet holds a set of items and ranges which can be displayed in a * An ItemSet holds a set of items and ranges which can be displayed in a
* range. The width is determined by the parent of the ItemSet, and the height * range. The width is determined by the parent of the ItemSet, and the height
* is determined by the size of the items. * is determined by the size of the items.
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] See ItemSet.setOptions for the available
* options.
* @param {Panel} backgroundPanel Panel which can be used to display the
* vertical lines of box items.
* @param {Panel} axisPanel Panel on the axis where the dots of box-items
* can be displayed.
* @param {Object} [options] See ItemSet.setOptions for the available options.
* @constructor ItemSet * @constructor ItemSet
* @extends Panel * @extends Panel
*/ */
// TODO: improve performance by replacing all Array.forEach with a for loop
function ItemSet(parent, depends, options) {
function ItemSet(backgroundPanel, axisPanel, options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
// event listeners
this.eventListeners = {
dragstart: this._onDragStart.bind(this),
drag: this._onDrag.bind(this),
dragend: this._onDragEnd.bind(this)
};
// one options object is shared by this itemset and all its items // one options object is shared by this itemset and all its items
this.options = options || {}; this.options = options || {};
this.defaultOptions = {
type: 'box',
align: 'center',
orientation: 'bottom',
margin: {
axis: 20,
item: 10
},
padding: 5
};
this.backgroundPanel = backgroundPanel;
this.axisPanel = axisPanel;
this.itemOptions = Object.create(this.options);
this.dom = {}; this.dom = {};
this.hammer = null;
var me = this; var me = this;
this.itemsData = null; // DataSet this.itemsData = null; // DataSet
@ -45,31 +28,33 @@ function ItemSet(parent, depends, options) {
// data change listeners // data change listeners
this.listeners = { this.listeners = {
'add': function (event, params, senderId) { 'add': function (event, params, senderId) {
if (senderId != me.id) {
me._onAdd(params.items);
}
if (senderId != me.id) me._onAdd(params.items);
}, },
'update': function (event, params, senderId) { 'update': function (event, params, senderId) {
if (senderId != me.id) {
me._onUpdate(params.items);
}
if (senderId != me.id) me._onUpdate(params.items);
}, },
'remove': function (event, params, senderId) { 'remove': function (event, params, senderId) {
if (senderId != me.id) {
me._onRemove(params.items);
}
if (senderId != me.id) me._onRemove(params.items);
} }
}; };
this.items = {}; // object with an Item for every data item
this.items = {}; // object with an Item for every data item
this.orderedItems = {
byStart: [],
byEnd: []
};
this.visibleItems = []; // visible, ordered items
this.visibleItemsStart = 0; // start index of visible items in this.orderedItems // TODO: cleanup
this.visibleItemsEnd = 0; // start index of visible items in this.orderedItems // TODO: cleanup
this.selection = []; // list with the ids of all selected nodes this.selection = []; // list with the ids of all selected nodes
this.queue = {}; // queue with id/actions: 'add', 'update', 'delete' this.queue = {}; // queue with id/actions: 'add', 'update', 'delete'
this.stack = new Stack(this, Object.create(this.options));
this.conversion = null;
this.stack = new Stack(Object.create(this.options));
this.stackDirty = true; // if true, all items will be restacked on next repaint
this.touchParams = {}; // stores properties while dragging this.touchParams = {}; // stores properties while dragging
// TODO: ItemSet should also attach event listeners for rangechange and rangechanged, like timeaxis
// create the HTML DOM
this._create();
} }
ItemSet.prototype = new Panel(); ItemSet.prototype = new Panel();
@ -82,6 +67,42 @@ ItemSet.types = {
point: ItemPoint point: ItemPoint
}; };
/**
* Create the HTML DOM for the ItemSet
*/
ItemSet.prototype._create = function _create(){
var frame = document.createElement('div');
frame['timeline-itemset'] = this;
this.frame = frame;
// create background panel
var background = document.createElement('div');
background.className = 'background';
this.backgroundPanel.frame.appendChild(background);
this.dom.background = background;
// create foreground panel
var foreground = document.createElement('div');
foreground.className = 'foreground';
frame.appendChild(foreground);
this.dom.foreground = foreground;
// create axis panel
var axis = document.createElement('div');
axis.className = 'axis';
this.dom.axis = axis;
this.axisPanel.frame.appendChild(axis);
// attach event listeners
// TODO: use event listeners from the rootpanel to improve performance?
this.hammer = Hammer(frame, {
prevent_default: true
});
this.hammer.on('dragstart', this._onDragStart.bind(this));
this.hammer.on('drag', this._onDrag.bind(this));
this.hammer.on('dragend', this._onDragEnd.bind(this));
};
/** /**
* Set options for the ItemSet. Existing options will be extended/overwritten. * Set options for the ItemSet. Existing options will be extended/overwritten.
* @param {Object} [options] The following options are available: * @param {Object} [options] The following options are available:
@ -112,54 +133,36 @@ ItemSet.types = {
*/ */
ItemSet.prototype.setOptions = Component.prototype.setOptions; ItemSet.prototype.setOptions = Component.prototype.setOptions;
/** /**
* Set controller for this component
* @param {Controller | null} controller
* Hide the component from the DOM
*/ */
ItemSet.prototype.setController = function setController (controller) {
var event;
// unregister old event listeners
if (this.controller) {
for (event in this.eventListeners) {
if (this.eventListeners.hasOwnProperty(event)) {
this.controller.off(event, this.eventListeners[event]);
}
}
ItemSet.prototype.hide = function hide() {
// remove the axis with dots
if (this.dom.axis.parentNode) {
this.dom.axis.parentNode.removeChild(this.dom.axis);
} }
this.controller = controller || null;
// register new event listeners
if (this.controller) {
for (event in this.eventListeners) {
if (this.eventListeners.hasOwnProperty(event)) {
this.controller.on(event, this.eventListeners[event]);
}
}
// remove the background with vertical lines
if (this.dom.background.parentNode) {
this.dom.background.parentNode.removeChild(this.dom.background);
} }
}; };
// attach event listeners for dragging items to the controller
(function (me) {
var _controller = null;
var _onDragStart = null;
var _onDrag = null;
var _onDragEnd = null;
Object.defineProperty(me, 'controller', {
get: function () {
return _controller;
},
set: function (controller) {
}
});
}) (this);
/**
* Show the component in the DOM (when not already visible).
* @return {Boolean} changed
*/
ItemSet.prototype.show = function show() {
// show axis with dots
if (!this.dom.axis.parentNode) {
this.axisPanel.frame.appendChild(this.dom.axis);
}
// show background with vertical lines
if (!this.dom.background.parentNode) {
this.backgroundPanel.frame.appendChild(this.dom.background);
}
};
/** /**
* Set range (start and end). * Set range (start and end).
@ -181,7 +184,7 @@ ItemSet.prototype.setRange = function setRange(range) {
* unselected. * unselected.
*/ */
ItemSet.prototype.setSelection = function setSelection(ids) { ItemSet.prototype.setSelection = function setSelection(ids) {
var i, ii, id, item, selection;
var i, ii, id, item;
if (ids) { if (ids) {
if (!Array.isArray(ids)) { if (!Array.isArray(ids)) {
@ -205,10 +208,6 @@ ItemSet.prototype.setSelection = function setSelection(ids) {
item.select(); item.select();
} }
} }
if (this.controller) {
this.requestRepaint();
}
} }
}; };
@ -235,184 +234,151 @@ ItemSet.prototype._deselect = function _deselect(id) {
} }
}; };
/**
* Return the item sets frame
* @returns {HTMLElement} frame
*/
ItemSet.prototype.getFrame = function getFrame() {
return this.frame;
};
/** /**
* Repaint the component * Repaint the component
* @return {Boolean} changed
* @return {boolean} Returns true if the component is resized
*/ */
ItemSet.prototype.repaint = function repaint() { ItemSet.prototype.repaint = function repaint() {
var changed = 0,
update = util.updateProperty,
asSize = util.option.asSize,
var asSize = util.option.asSize,
asString = util.option.asString,
options = this.options, options = this.options,
orientation = this.getOption('orientation'), orientation = this.getOption('orientation'),
defaultOptions = this.defaultOptions,
frame = this.frame; frame = this.frame;
if (!frame) {
frame = document.createElement('div');
frame.className = 'itemset';
frame['timeline-itemset'] = this;
// update className
frame.className = 'itemset' + (options.className ? (' ' + asString(options.className)) : '');
var className = options.className;
if (className) {
util.addClassName(frame, util.option.asString(className));
}
// check whether zoomed (in that case we need to re-stack everything)
var visibleInterval = this.range.end - this.range.start;
var zoomed = (visibleInterval != this.lastVisibleInterval) || (this.width != this.lastWidth);
this.lastVisibleInterval = visibleInterval;
this.lastWidth = this.width;
// create background panel
var background = document.createElement('div');
background.className = 'background';
frame.appendChild(background);
this.dom.background = background;
// create foreground panel
var foreground = document.createElement('div');
foreground.className = 'foreground';
frame.appendChild(foreground);
this.dom.foreground = foreground;
// create axis panel
var axis = document.createElement('div');
axis.className = 'itemset-axis';
//frame.appendChild(axis);
this.dom.axis = axis;
this.frame = frame;
changed += 1;
/* TODO: implement+fix smarter way to update visible items
// find the first visible item
// TODO: use faster search, not linear
var byEnd = this.orderedItems.byEnd;
var start = 0;
var item = null;
while ((item = byEnd[start]) &&
(('end' in item.data) ? item.data.end : item.data.start) < this.range.start) {
start++;
} }
if (!this.parent) {
throw new Error('Cannot repaint itemset: no parent attached');
}
var parentContainer = this.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint itemset: parent has no container element');
}
if (!frame.parentNode) {
parentContainer.appendChild(frame);
changed += 1;
}
if (!this.dom.axis.parentNode) {
parentContainer.appendChild(this.dom.axis);
changed += 1;
// find the last visible item
// TODO: use faster search, not linear
var byStart = this.orderedItems.byStart;
var end = 0;
while ((item = byStart[end]) && item.data.start < this.range.end) {
end++;
} }
// reposition frame
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'top', asSize(options.top, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
changed += update(frame.style, 'height', asSize(options.height, this.height + 'px'));
console.log('visible items', start, end); // TODO: cleanup
console.log('visible item ids', byStart[start] && byStart[start].id, byEnd[end-1] && byEnd[end-1].id); // TODO: cleanup
// reposition axis
changed += update(this.dom.axis.style, 'left', asSize(options.left, '0px'));
changed += update(this.dom.axis.style, 'width', asSize(options.width, '100%'));
if (orientation == 'bottom') {
changed += update(this.dom.axis.style, 'top', (this.height + this.top) + 'px');
}
else { // orientation == 'top'
changed += update(this.dom.axis.style, 'top', this.top + 'px');
this.visibleItems = [];
var i = start;
item = byStart[i];
var lastItem = byEnd[end];
while (item && item !== lastItem) {
this.visibleItems.push(item);
item = byStart[++i];
} }
this.stack.order(this.visibleItems);
this._updateConversion();
var me = this,
queue = this.queue,
itemsData = this.itemsData,
items = this.items,
dataOptions = {
// TODO: cleanup
// fields: [(itemsData && itemsData.fieldId || 'id'), 'start', 'end', 'content', 'type', 'className']
};
// show/hide added/changed/removed items
for (var id in queue) {
if (queue.hasOwnProperty(id)) {
var entry = queue[id],
item = items[id],
action = entry.action;
//noinspection FallthroughInSwitchStatementJS
switch (action) {
case 'add':
case 'update':
var itemData = itemsData && itemsData.get(id, dataOptions);
if (itemData) {
var type = itemData.type ||
(itemData.start && itemData.end && 'range') ||
options.type ||
'box';
var constructor = ItemSet.types[type];
// TODO: how to handle items with invalid data? hide them and give a warning? or throw an error?
if (item) {
// update item
if (!constructor || !(item instanceof constructor)) {
// item type has changed, hide and delete the item
changed += item.hide();
item = null;
}
else {
item.data = itemData; // TODO: create a method item.setData ?
changed++;
}
}
if (!item) {
// create item
if (constructor) {
item = new constructor(me, itemData, options, defaultOptions);
item.id = entry.id; // we take entry.id, as id itself is stringified
changed++;
}
else {
throw new TypeError('Unknown item type "' + type + '"');
}
}
// force a repaint (not only a reposition)
item.repaint();
items[id] = item;
}
// show visible items
for (var i = 0, ii = this.visibleItems.length; i < ii; i++) {
item = this.visibleItems[i];
// update queue
delete queue[id];
break;
if (!item.displayed) item.show();
item.top = null; // reset stacking position
case 'remove':
if (item) {
// remove the item from the set selected items
if (item.selected) {
me._deselect(id);
}
// reposition item horizontally
item.repositionX();
}
*/
// remove DOM of the item
changed += item.hide();
}
// simple, brute force calculation of visible items
// TODO: replace with a faster, more sophisticated solution
this.visibleItems = [];
for (var id in this.items) {
if (this.items.hasOwnProperty(id)) {
var item = this.items[id];
if (item.isVisible(this.range)) {
if (!item.displayed) item.show();
// update lists
delete items[id];
delete queue[id];
break;
// reposition item horizontally
item.repositionX();
default:
console.log('Error: unknown action "' + action + '"');
this.visibleItems.push(item);
}
else {
if (item.displayed) item.hide();
} }
} }
} }
// reposition all items. Show items only when in the visible area
util.forEach(this.items, function (item) {
if (item.visible) {
changed += item.show();
item.reposition();
}
else {
changed += item.hide();
}
});
// reposition visible items vertically
//this.stack.order(this.visibleItems); // TODO: improve ordering
var force = this.stackDirty || zoomed; // force re-stacking of all items if true
this.stack.stack(this.visibleItems, force);
this.stackDirty = false;
for (var i = 0, ii = this.visibleItems.length; i < ii; i++) {
this.visibleItems[i].repositionY();
}
// recalculate the height of the itemset
var marginAxis = (options.margin && 'axis' in options.margin) ? options.margin.axis : this.itemOptions.margin.axis,
marginItem = (options.margin && 'item' in options.margin) ? options.margin.item : this.itemOptions.margin.item,
height;
// determine the height from the stacked items
var visibleItems = this.visibleItems;
if (visibleItems.length) {
var min = visibleItems[0].top;
var max = visibleItems[0].top + visibleItems[0].height;
util.forEach(visibleItems, function (item) {
min = Math.min(min, item.top);
max = Math.max(max, (item.top + item.height));
});
height = (max - min) + marginAxis + marginItem;
}
else {
height = marginAxis + marginItem;
}
return (changed > 0);
// reposition frame
frame.style.left = asSize(options.left, '');
frame.style.right = asSize(options.right, '');
frame.style.top = asSize((orientation == 'top') ? '0' : '');
frame.style.bottom = asSize((orientation == 'top') ? '' : '0');
frame.style.width = asSize(options.width, '100%');
frame.style.height = asSize(height);
//frame.style.height = asSize('height' in options ? options.height : height); // TODO: reckon with height
// calculate actual size and position
this.top = frame.offsetTop;
this.left = frame.offsetLeft;
this.width = frame.offsetWidth;
this.height = height;
// reposition axis
this.dom.axis.style.left = asSize(options.left, '0');
this.dom.axis.style.right = asSize(options.right, '');
this.dom.axis.style.width = asSize(options.width, '100%');
this.dom.axis.style.height = asSize(0);
this.dom.axis.style.top = asSize((orientation == 'top') ? '0' : '');
this.dom.axis.style.bottom = asSize((orientation == 'top') ? '' : '0');
return this._isResized();
}; };
/** /**
@ -439,90 +405,6 @@ ItemSet.prototype.getAxis = function getAxis() {
return this.dom.axis; return this.dom.axis;
}; };
/**
* Reflow the component
* @return {Boolean} resized
*/
ItemSet.prototype.reflow = function reflow () {
var changed = 0,
options = this.options,
marginAxis = (options.margin && 'axis' in options.margin) ? options.margin.axis : this.defaultOptions.margin.axis,
marginItem = (options.margin && 'item' in options.margin) ? options.margin.item : this.defaultOptions.margin.item,
update = util.updateProperty,
asNumber = util.option.asNumber,
asSize = util.option.asSize,
frame = this.frame;
if (frame) {
this._updateConversion();
util.forEach(this.items, function (item) {
changed += item.reflow();
});
// TODO: stack.update should be triggered via an event, in stack itself
// TODO: only update the stack when there are changed items
this.stack.update();
var maxHeight = asNumber(options.maxHeight);
var fixedHeight = (asSize(options.height) != null);
var height;
if (fixedHeight) {
height = frame.offsetHeight;
}
else {
// height is not specified, determine the height from the height and positioned items
var visibleItems = this.stack.ordered; // TODO: not so nice way to get the filtered items
if (visibleItems.length) {
var min = visibleItems[0].top;
var max = visibleItems[0].top + visibleItems[0].height;
util.forEach(visibleItems, function (item) {
min = Math.min(min, item.top);
max = Math.max(max, (item.top + item.height));
});
height = (max - min) + marginAxis + marginItem;
}
else {
height = marginAxis + marginItem;
}
}
if (maxHeight != null) {
height = Math.min(height, maxHeight);
}
changed += update(this, 'height', height);
// calculate height from items
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
changed += update(this, 'width', frame.offsetWidth);
}
else {
changed += 1;
}
return (changed > 0);
};
/**
* Hide this component from the DOM
* @return {Boolean} changed
*/
ItemSet.prototype.hide = function hide() {
var changed = false;
// remove the DOM
if (this.frame && this.frame.parentNode) {
this.frame.parentNode.removeChild(this.frame);
changed = true;
}
if (this.dom.axis && this.dom.axis.parentNode) {
this.dom.axis.parentNode.removeChild(this.dom.axis);
changed = true;
}
return changed;
};
/** /**
* Set items * Set items
* @param {vis.DataSet | null} items * @param {vis.DataSet | null} items
@ -587,7 +469,9 @@ ItemSet.prototype.removeItem = function removeItem (id) {
// confirm deletion // confirm deletion
this.options.onRemove(item, function (item) { this.options.onRemove(item, function (item) {
if (item) { if (item) {
dataset.remove(item);
// remove by id here, it is possible that an item has no id defined
// itself, so better not delete by the item itself
dataset.remove(id);
} }
}); });
} }
@ -599,17 +483,58 @@ ItemSet.prototype.removeItem = function removeItem (id) {
* @private * @private
*/ */
ItemSet.prototype._onUpdate = function _onUpdate(ids) { ItemSet.prototype._onUpdate = function _onUpdate(ids) {
this._toQueue('update', ids);
var me = this,
items = this.items,
itemOptions = this.itemOptions;
ids.forEach(function (id) {
var itemData = me.itemsData.get(id),
item = items[id],
type = itemData.type ||
(itemData.start && itemData.end && 'range') ||
me.options.type ||
'box';
var constructor = ItemSet.types[type];
if (item) {
// update item
if (!constructor || !(item instanceof constructor)) {
// item type has changed, hide and delete the item
item.hide();
item = null;
}
else {
item.data = itemData; // TODO: create a method item.setData ?
}
}
if (!item) {
// create item
if (constructor) {
item = new constructor(me, itemData, me.options, itemOptions);
item.id = id;
}
else {
throw new TypeError('Unknown item type "' + type + '"');
}
}
me.items[id] = item;
});
this._order();
this.stackDirty = true; // force re-stacking of all items next repaint
this.emit('change');
}; };
/** /**
* Handle changed items
* Handle added items
* @param {Number[]} ids * @param {Number[]} ids
* @private * @private
*/ */
ItemSet.prototype._onAdd = function _onAdd(ids) {
this._toQueue('add', ids);
};
ItemSet.prototype._onAdd = ItemSet.prototype._onUpdate;
/** /**
* Handle removed items * Handle removed items
@ -617,73 +542,42 @@ ItemSet.prototype._onAdd = function _onAdd(ids) {
* @private * @private
*/ */
ItemSet.prototype._onRemove = function _onRemove(ids) { ItemSet.prototype._onRemove = function _onRemove(ids) {
this._toQueue('remove', ids);
};
/**
* Put items in the queue to be added/updated/remove
* @param {String} action can be 'add', 'update', 'remove'
* @param {Number[]} ids
*/
ItemSet.prototype._toQueue = function _toQueue(action, ids) {
var queue = this.queue;
var count = 0;
var me = this;
ids.forEach(function (id) { ids.forEach(function (id) {
queue[id] = {
id: id,
action: action
};
var item = me.items[id];
if (item) {
count++;
item.hide();
delete me.items[id];
delete me.visibleItems[id];
// remove from selection
var index = me.selection.indexOf(id);
if (index != -1) me.selection.splice(index, 1);
}
}); });
if (this.controller) {
//this.requestReflow();
this.requestRepaint();
if (count) {
// update order
this._order();
this.stackDirty = true; // force re-stacking of all items next repaint
this.emit('change');
} }
}; };
/** /**
* Calculate the scale and offset to convert a position on screen to the
* corresponding date and vice versa.
* After the method _updateConversion is executed once, the methods toTime
* and toScreen can be used.
* Order the items
* @private * @private
*/ */
ItemSet.prototype._updateConversion = function _updateConversion() {
var range = this.range;
if (!range) {
throw new Error('No range configured');
}
if (range.conversion) {
this.conversion = range.conversion(this.width);
}
else {
this.conversion = Range.conversion(range.start, range.end, this.width);
}
};
/**
* Convert a position on screen (pixels) to a datetime
* Before this method can be used, the method _updateConversion must be
* executed once.
* @param {int} x Position on the screen in pixels
* @return {Date} time The datetime the corresponds with given position x
*/
ItemSet.prototype.toTime = function toTime(x) {
var conversion = this.conversion;
return new Date(x / conversion.scale + conversion.offset);
};
/**
* Convert a datetime (Date object) into a position on the screen
* Before this method can be used, the method _updateConversion must be
* executed once.
* @param {Date} time A date
* @return {int} x The position on the screen in pixels which corresponds
* with the given date.
*/
ItemSet.prototype.toScreen = function toScreen(time) {
var conversion = this.conversion;
return (time.valueOf() - conversion.offset) * conversion.scale;
ItemSet.prototype._order = function _order() {
var array = util.toArray(this.items);
this.orderedItems.byStart = array;
this.orderedItems.byEnd = [].concat(array);
// reorder the items
this.stack.orderByStart(this.orderedItems.byStart);
this.stack.orderByEnd(this.orderedItems.byEnd);
}; };
/** /**
@ -746,7 +640,8 @@ ItemSet.prototype._onDrag = function (event) {
if (this.touchParams.itemProps) { if (this.touchParams.itemProps) {
var snap = this.options.snap || null, var snap = this.options.snap || null,
deltaX = event.gesture.deltaX, deltaX = event.gesture.deltaX,
offset = deltaX / this.conversion.scale;
scale = (this.width / (this.range.end - this.range.start)),
offset = deltaX / scale;
// move // move
this.touchParams.itemProps.forEach(function (props) { this.touchParams.itemProps.forEach(function (props) {
@ -764,7 +659,8 @@ ItemSet.prototype._onDrag = function (event) {
// TODO: implement dragging from one group to another // TODO: implement dragging from one group to another
this.requestReflow();
this.stackDirty = true; // force re-stacking of all items next repaint
this.emit('change');
event.stopPropagation(); event.stopPropagation();
} }
@ -780,8 +676,7 @@ ItemSet.prototype._onDragEnd = function (event) {
// prepare a change set for the changed items // prepare a change set for the changed items
var changes = [], var changes = [],
me = this, me = this,
dataset = this._myDataSet(),
type;
dataset = this._myDataSet();
this.touchParams.itemProps.forEach(function (props) { this.touchParams.itemProps.forEach(function (props) {
var id = props.item.id, var id = props.item.id,
@ -802,13 +697,16 @@ ItemSet.prototype._onDragEnd = function (event) {
me.options.onMove(item, function (item) { me.options.onMove(item, function (item) {
if (item) { if (item) {
// apply changes // apply changes
item[dataset.fieldId] = id; // ensure the item contains its id (can be undefined)
changes.push(item); changes.push(item);
} }
else { else {
// restore original values // restore original values
if ('start' in props) props.item.data.start = props.start; if ('start' in props) props.item.data.start = props.start;
if ('end' in props) props.item.data.end = props.end; if ('end' in props) props.item.data.end = props.end;
me.requestReflow();
me.stackDirty = true; // force re-stacking of all items next repaint
me.emit('change');
} }
}); });
} }

+ 118
- 60
src/timeline/component/Panel.js View File

@ -1,8 +1,5 @@
/** /**
* A panel can contain components * A panel can contain components
* @param {Component} [parent]
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] Available parameters: * @param {Object} [options] Available parameters:
* {String | Number | function} [left] * {String | Number | function} [left]
* {String | Number | function} [top] * {String | Number | function} [top]
@ -12,12 +9,15 @@
* @constructor Panel * @constructor Panel
* @extends Component * @extends Component
*/ */
function Panel(parent, depends, options) {
function Panel(options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.parent = null;
this.childs = [];
this.options = options || {}; this.options = options || {};
// create frame
this.frame = (typeof document !== 'undefined') ? document.createElement('div') : null;
} }
Panel.prototype = new Component(); Panel.prototype = new Component();
@ -34,79 +34,137 @@ Panel.prototype = new Component();
Panel.prototype.setOptions = Component.prototype.setOptions; Panel.prototype.setOptions = Component.prototype.setOptions;
/** /**
* Get the container element of the panel, which can be used by a child to
* add its own widgets.
* @returns {HTMLElement} container
* Get the outer frame of the panel
* @returns {HTMLElement} frame
*/ */
Panel.prototype.getContainer = function () {
Panel.prototype.getFrame = function () {
return this.frame; return this.frame;
}; };
/** /**
* Repaint the component
* @return {Boolean} changed
* Append a child to the panel
* @param {Component} child
*/ */
Panel.prototype.repaint = function () {
var changed = 0,
update = util.updateProperty,
asSize = util.option.asSize,
options = this.options,
frame = this.frame;
if (!frame) {
frame = document.createElement('div');
frame.className = 'vpanel';
var className = options.className;
if (className) {
if (typeof className == 'function') {
util.addClassName(frame, String(className()));
Panel.prototype.appendChild = function (child) {
this.childs.push(child);
child.parent = this;
// attach to the DOM
var frame = child.getFrame();
if (frame) {
if (frame.parentNode) {
frame.parentNode.removeChild(frame);
}
this.frame.appendChild(frame);
}
};
/**
* Insert a child to the panel
* @param {Component} child
* @param {Component} beforeChild
*/
Panel.prototype.insertBefore = function (child, beforeChild) {
var index = this.childs.indexOf(beforeChild);
if (index != -1) {
this.childs.splice(index, 0, child);
child.parent = this;
// attach to the DOM
var frame = child.getFrame();
if (frame) {
if (frame.parentNode) {
frame.parentNode.removeChild(frame);
}
var beforeFrame = beforeChild.getFrame();
if (beforeFrame) {
this.frame.insertBefore(frame, beforeFrame);
} }
else { else {
util.addClassName(frame, String(className));
this.frame.appendChild(frame);
} }
} }
this.frame = frame;
changed += 1;
} }
if (!frame.parentNode) {
if (!this.parent) {
throw new Error('Cannot repaint panel: no parent attached');
}
var parentContainer = this.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint panel: parent has no container element');
};
/**
* Remove a child from the panel
* @param {Component} child
*/
Panel.prototype.removeChild = function (child) {
var index = this.childs.indexOf(child);
if (index != -1) {
this.childs.splice(index, 1);
child.parent = null;
// remove from the DOM
var frame = child.getFrame();
if (frame && frame.parentNode) {
this.frame.removeChild(frame);
} }
parentContainer.appendChild(frame);
changed += 1;
} }
};
changed += update(frame.style, 'top', asSize(options.top, '0px'));
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
changed += update(frame.style, 'height', asSize(options.height, '100%'));
return (changed > 0);
/**
* Test whether the panel contains given child
* @param {Component} child
*/
Panel.prototype.hasChild = function (child) {
var index = this.childs.indexOf(child);
return (index != -1);
}; };
/** /**
* Reflow the component
* @return {Boolean} resized
* Repaint the component
* @return {boolean} Returns true if the component was resized since previous repaint
*/ */
Panel.prototype.reflow = function () {
var changed = 0,
update = util.updateProperty,
frame = this.frame;
Panel.prototype.repaint = function () {
var asString = util.option.asString,
options = this.options,
frame = this.getFrame();
if (frame) {
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
changed += update(this, 'width', frame.offsetWidth);
changed += update(this, 'height', frame.offsetHeight);
}
else {
changed += 1;
// update className
frame.className = 'vpanel' + (options.className ? (' ' + asString(options.className)) : '');
// repaint the child components
var childsResized = this._repaintChilds();
// update frame size
this._updateSize();
return this._isResized() || childsResized;
};
/**
* Repaint all childs of the panel
* @return {boolean} Returns true if the component is resized
* @private
*/
Panel.prototype._repaintChilds = function () {
var resized = false;
for (var i = 0, ii = this.childs.length; i < ii; i++) {
resized = this.childs[i].repaint() || resized;
} }
return resized;
};
/**
* Apply the size from options to the panel, and recalculate it's actual size.
* @private
*/
Panel.prototype._updateSize = function () {
// apply size
this.frame.style.top = util.option.asSize(this.options.top);
this.frame.style.bottom = util.option.asSize(this.options.bottom);
this.frame.style.left = util.option.asSize(this.options.left);
this.frame.style.right = util.option.asSize(this.options.right);
this.frame.style.width = util.option.asSize(this.options.width, '100%');
this.frame.style.height = util.option.asSize(this.options.height, '');
return (changed > 0);
// get actual size
this.top = this.frame.offsetTop;
this.left = this.frame.offsetLeft;
this.width = this.frame.offsetWidth;
this.height = this.frame.offsetHeight;
}; };

+ 79
- 132
src/timeline/component/RootPanel.js View File

@ -10,32 +10,53 @@ function RootPanel(container, options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
this.container = container; this.container = container;
// create functions to be used as DOM event listeners
var me = this;
this.hammer = null;
this.options = options || {};
this.defaultOptions = {
autoResize: true
};
// create the HTML DOM
this._create();
// attach the root panel to the provided container
if (!this.container) throw new Error('Cannot repaint root panel: no container attached');
this.container.appendChild(this.getFrame());
this._initWatch();
}
RootPanel.prototype = new Panel();
// create listeners for all interesting events, these events will be emitted
// via the controller
/**
* Create the HTML DOM for the root panel
*/
RootPanel.prototype._create = function _create() {
// create frame
this.frame = document.createElement('div');
// create event listeners for all interesting events, these events will be
// emitted via emitter
this.hammer = Hammer(this.frame, {
prevent_default: true
});
this.listeners = {};
var me = this;
var events = [ var events = [
'touch', 'pinch', 'tap', 'doubletap', 'hold', 'touch', 'pinch', 'tap', 'doubletap', 'hold',
'dragstart', 'drag', 'dragend', 'dragstart', 'drag', 'dragend',
'mousewheel', 'DOMMouseScroll' // DOMMouseScroll is for Firefox 'mousewheel', 'DOMMouseScroll' // DOMMouseScroll is for Firefox
]; ];
this.listeners = {};
events.forEach(function (event) { events.forEach(function (event) {
me.listeners[event] = function () {
var listener = function () {
var args = [event].concat(Array.prototype.slice.call(arguments, 0)); var args = [event].concat(Array.prototype.slice.call(arguments, 0));
me.controller.emit.apply(me.controller, args);
me.emit.apply(me, args);
}; };
me.hammer.on(event, listener);
me.listeners[event] = listener;
}); });
this.options = options || {};
this.defaultOptions = {
autoResize: true
};
}
RootPanel.prototype = new Panel();
};
/** /**
* Set options. Will extend the current options. * Set options. Will extend the current options.
@ -47,80 +68,53 @@ RootPanel.prototype = new Panel();
* {String | Number | function} [height] * {String | Number | function} [height]
* {Boolean | function} [autoResize] * {Boolean | function} [autoResize]
*/ */
RootPanel.prototype.setOptions = Component.prototype.setOptions;
/**
* Repaint the component
* @return {Boolean} changed
*/
RootPanel.prototype.repaint = function () {
var changed = 0,
update = util.updateProperty,
asSize = util.option.asSize,
options = this.options,
frame = this.frame;
RootPanel.prototype.setOptions = function setOptions(options) {
if (options) {
util.extend(this.options, options);
if (!frame) {
frame = document.createElement('div');
this.frame = frame;
this._registerListeners();
changed += 1;
}
if (!frame.parentNode) {
if (!this.container) {
throw new Error('Cannot repaint root panel: no container attached');
}
this.container.appendChild(frame);
changed += 1;
}
this.repaint();
frame.className = 'vis timeline rootpanel ' + options.orientation +
(options.editable ? ' editable' : '');
var className = options.className;
if (className) {
util.addClassName(frame, util.option.asString(className));
this._initWatch();
} }
};
changed += update(frame.style, 'top', asSize(options.top, '0px'));
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
changed += update(frame.style, 'height', asSize(options.height, '100%'));
this._updateWatch();
return (changed > 0);
/**
* Get the frame of the root panel
*/
RootPanel.prototype.getFrame = function getFrame() {
return this.frame;
}; };
/** /**
* Reflow the component
* @return {Boolean} resized
* Repaint the root panel
*/ */
RootPanel.prototype.reflow = function () {
var changed = 0,
update = util.updateProperty,
frame = this.frame;
if (frame) {
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
changed += update(this, 'width', frame.offsetWidth);
changed += update(this, 'height', frame.offsetHeight);
}
else {
changed += 1;
RootPanel.prototype.repaint = function repaint() {
// update class name
var options = this.options;
var className = 'vis timeline rootpanel ' + options.orientation + (options.editable ? ' editable' : '');
if (options.className) className += ' ' + util.option.asString(className);
this.frame.className = className;
// repaint the child components
var childsResized = this._repaintChilds();
// update frame size
this.frame.style.maxHeight = util.option.asSize(this.options.maxHeight, '');
this._updateSize();
// if the root panel or any of its childs is resized, repaint again,
// as other components may need to be resized accordingly
var resized = this._isResized() || childsResized;
if (resized) {
setTimeout(this.repaint.bind(this), 0);
} }
return (changed > 0);
}; };
/** /**
* Update watching for resize, depending on the current option
* Initialize watching when option autoResize is true
* @private * @private
*/ */
RootPanel.prototype._updateWatch = function () {
RootPanel.prototype._initWatch = function _initWatch() {
var autoResize = this.getOption('autoResize'); var autoResize = this.getOption('autoResize');
if (autoResize) { if (autoResize) {
this._watch(); this._watch();
@ -135,12 +129,12 @@ RootPanel.prototype._updateWatch = function () {
* automatically redraw itself. * automatically redraw itself.
* @private * @private
*/ */
RootPanel.prototype._watch = function () {
RootPanel.prototype._watch = function _watch() {
var me = this; var me = this;
this._unwatch(); this._unwatch();
var checkSize = function () {
var checkSize = function checkSize() {
var autoResize = me.getOption('autoResize'); var autoResize = me.getOption('autoResize');
if (!autoResize) { if (!autoResize) {
// stop watching when the option autoResize is changed to false // stop watching when the option autoResize is changed to false
@ -150,9 +144,12 @@ RootPanel.prototype._watch = function () {
if (me.frame) { if (me.frame) {
// check whether the frame is resized // check whether the frame is resized
if ((me.frame.clientWidth != me.width) ||
(me.frame.clientHeight != me.height)) {
me.requestReflow();
if ((me.frame.clientWidth != me.lastWidth) ||
(me.frame.clientHeight != me.lastHeight)) {
me.lastWidth = me.frame.clientWidth;
me.lastHeight = me.frame.clientHeight;
me.repaint();
// TODO: emit a resize event instead?
} }
} }
}; };
@ -167,7 +164,7 @@ RootPanel.prototype._watch = function () {
* Stop watching for a resize of the frame. * Stop watching for a resize of the frame.
* @private * @private
*/ */
RootPanel.prototype._unwatch = function () {
RootPanel.prototype._unwatch = function _unwatch() {
if (this.watchTimer) { if (this.watchTimer) {
clearInterval(this.watchTimer); clearInterval(this.watchTimer);
this.watchTimer = undefined; this.watchTimer = undefined;
@ -175,53 +172,3 @@ RootPanel.prototype._unwatch = function () {
// TODO: remove event listener on window.resize // TODO: remove event listener on window.resize
}; };
/**
* Set controller for this component, or remove current controller by passing
* null as parameter value.
* @param {Controller | null} controller
*/
RootPanel.prototype.setController = function setController (controller) {
this.controller = controller || null;
if (this.controller) {
this._registerListeners();
}
else {
this._unregisterListeners();
}
};
/**
* Register event emitters emitted by the rootpanel
* @private
*/
RootPanel.prototype._registerListeners = function () {
if (this.frame && this.controller && !this.hammer) {
this.hammer = Hammer(this.frame, {
prevent_default: true
});
for (var event in this.listeners) {
if (this.listeners.hasOwnProperty(event)) {
this.hammer.on(event, this.listeners[event]);
}
}
}
};
/**
* Unregister event emitters from the rootpanel
* @private
*/
RootPanel.prototype._unregisterListeners = function () {
if (this.hammer) {
for (var event in this.listeners) {
if (this.listeners.hasOwnProperty(event)) {
this.hammer.off(event, this.listeners[event]);
}
}
this.hammer = null;
}
};

+ 191
- 277
src/timeline/component/TimeAxis.js View File

@ -1,17 +1,12 @@
/** /**
* A horizontal time axis * A horizontal time axis
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] See TimeAxis.setOptions for the available * @param {Object} [options] See TimeAxis.setOptions for the available
* options. * options.
* @constructor TimeAxis * @constructor TimeAxis
* @extends Component * @extends Component
*/ */
function TimeAxis (parent, depends, options) {
function TimeAxis (options) {
this.id = util.randomUUID(); this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.dom = { this.dom = {
majorLines: [], majorLines: [],
@ -42,8 +37,10 @@ function TimeAxis (parent, depends, options) {
showMajorLabels: true showMajorLabels: true
}; };
this.conversion = null;
this.range = null; this.range = null;
// create the HTML DOM
this._create();
} }
TimeAxis.prototype = new Component(); TimeAxis.prototype = new Component();
@ -51,6 +48,13 @@ TimeAxis.prototype = new Component();
// TODO: comment options // TODO: comment options
TimeAxis.prototype.setOptions = Component.prototype.setOptions; TimeAxis.prototype.setOptions = Component.prototype.setOptions;
/**
* Create the HTML DOM for the TimeAxis
*/
TimeAxis.prototype._create = function _create() {
this.frame = document.createElement('div');
};
/** /**
* Set a range (start and end) * Set a range (start and end)
* @param {Range | Object} range A Range or an object containing start and end. * @param {Range | Object} range A Range or an object containing start and end.
@ -64,126 +68,70 @@ TimeAxis.prototype.setRange = function (range) {
}; };
/** /**
* Convert a position on screen (pixels) to a datetime
* @param {int} x Position on the screen in pixels
* @return {Date} time The datetime the corresponds with given position x
*/
TimeAxis.prototype.toTime = function(x) {
var conversion = this.conversion;
return new Date(x / conversion.scale + conversion.offset);
};
/**
* Convert a datetime (Date object) into a position on the screen
* @param {Date} time A date
* @return {int} x The position on the screen in pixels which corresponds
* with the given date.
* @private
* Get the outer frame of the time axis
* @return {HTMLElement} frame
*/ */
TimeAxis.prototype.toScreen = function(time) {
var conversion = this.conversion;
return (time.valueOf() - conversion.offset) * conversion.scale;
TimeAxis.prototype.getFrame = function getFrame() {
return this.frame;
}; };
/** /**
* Repaint the component * Repaint the component
* @return {Boolean} changed
* @return {boolean} Returns true if the component is resized
*/ */
TimeAxis.prototype.repaint = function () { TimeAxis.prototype.repaint = function () {
var changed = 0,
update = util.updateProperty,
asSize = util.option.asSize,
var asSize = util.option.asSize,
options = this.options, options = this.options,
orientation = this.getOption('orientation'),
props = this.props, props = this.props,
step = this.step;
var frame = this.frame;
if (!frame) {
frame = document.createElement('div');
this.frame = frame;
changed += 1;
}
frame.className = 'axis';
// TODO: custom className?
if (!frame.parentNode) {
if (!this.parent) {
throw new Error('Cannot repaint time axis: no parent attached');
}
var parentContainer = this.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint time axis: parent has no container element');
}
parentContainer.appendChild(frame);
frame = this.frame;
changed += 1;
}
// update classname
frame.className = 'timeaxis'; // TODO: add className from options if defined
var parent = frame.parentNode; var parent = frame.parentNode;
if (parent) { if (parent) {
var beforeChild = frame.nextSibling;
parent.removeChild(frame); // take frame offline while updating (is almost twice as fast)
var defaultTop = (orientation == 'bottom' && this.props.parentHeight && this.height) ?
(this.props.parentHeight - this.height) + 'px' :
'0px';
changed += update(frame.style, 'top', asSize(options.top, defaultTop));
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
changed += update(frame.style, 'height', asSize(options.height, this.height + 'px'));
// get characters width and height
this._repaintMeasureChars();
if (this.step) {
this._repaintStart();
step.first();
var xFirstMajorLabel = undefined;
var max = 0;
while (step.hasNext() && max < 1000) {
max++;
var cur = step.getCurrent(),
x = this.toScreen(cur),
isMajor = step.isMajor();
// TODO: lines must have a width, such that we can create css backgrounds
if (this.getOption('showMinorLabels')) {
this._repaintMinorText(x, step.getLabelMinor());
}
if (isMajor && this.getOption('showMajorLabels')) {
if (x > 0) {
if (xFirstMajorLabel == undefined) {
xFirstMajorLabel = x;
}
this._repaintMajorText(x, step.getLabelMajor());
}
this._repaintMajorLine(x);
}
else {
this._repaintMinorLine(x);
}
// calculate character width and height
this._calculateCharSize();
step.next();
}
// TODO: recalculate sizes only needed when parent is resized or options is changed
var orientation = this.getOption('orientation'),
showMinorLabels = this.getOption('showMinorLabels'),
showMajorLabels = this.getOption('showMajorLabels');
// create a major label on the left when needed
if (this.getOption('showMajorLabels')) {
var leftTime = this.toTime(0),
leftText = step.getLabelMajor(leftTime),
widthText = leftText.length * (props.majorCharWidth || 10) + 10; // upper bound estimation
// determine the width and height of the elemens for the axis
var parentHeight = this.parent.height;
props.minorLabelHeight = showMinorLabels ? props.minorCharHeight : 0;
props.majorLabelHeight = showMajorLabels ? props.majorCharHeight : 0;
this.height = props.minorLabelHeight + props.majorLabelHeight;
this.width = frame.offsetWidth; // TODO: only update the width when the frame is resized?
if (xFirstMajorLabel == undefined || widthText < xFirstMajorLabel) {
this._repaintMajorText(0, leftText);
}
}
props.minorLineHeight = parentHeight + props.minorLabelHeight;
props.minorLineWidth = 1; // TODO: really calculate width
props.majorLineHeight = parentHeight + this.height;
props.majorLineWidth = 1; // TODO: really calculate width
this._repaintEnd();
// take frame offline while updating (is almost twice as fast)
var beforeChild = frame.nextSibling;
parent.removeChild(frame);
// TODO: top/bottom positioning should be determined by options set in the Timeline, not here
if (orientation == 'top') {
frame.style.top = '0';
frame.style.left = '0';
frame.style.bottom = '';
frame.style.width = asSize(options.width, '100%');
frame.style.height = this.height + 'px';
}
else { // bottom
frame.style.top = '';
frame.style.bottom = '0';
frame.style.left = '0';
frame.style.width = asSize(options.width, '100%');
frame.style.height = this.height + 'px';
} }
this._repaintLabels();
this._repaintLine(); this._repaintLine();
// put frame online again // put frame online again
@ -195,34 +143,80 @@ TimeAxis.prototype.repaint = function () {
} }
} }
return (changed > 0);
return this._isResized();
}; };
/** /**
* Start a repaint. Move all DOM elements to a redundant list, where they
* can be picked for re-use, or can be cleaned up in the end
* Repaint major and minor text labels and vertical grid lines
* @private * @private
*/ */
TimeAxis.prototype._repaintStart = function () {
var dom = this.dom,
redundant = dom.redundant;
redundant.majorLines = dom.majorLines;
redundant.majorTexts = dom.majorTexts;
redundant.minorLines = dom.minorLines;
redundant.minorTexts = dom.minorTexts;
TimeAxis.prototype._repaintLabels = function () {
var orientation = this.getOption('orientation');
// calculate range and step
var start = util.convert(this.range.start, 'Number'),
end = util.convert(this.range.end, 'Number'),
minimumStep = this.options.toTime((this.props.minorCharWidth || 10) * 5).valueOf()
-this.options.toTime(0).valueOf();
var step = new TimeStep(new Date(start), new Date(end), minimumStep);
this.step = step;
// Move all DOM elements to a "redundant" list, where they
// can be picked for re-use, and clear the lists with lines and texts.
// At the end of the function _repaintLabels, left over elements will be cleaned up
var dom = this.dom;
dom.redundant.majorLines = dom.majorLines;
dom.redundant.majorTexts = dom.majorTexts;
dom.redundant.minorLines = dom.minorLines;
dom.redundant.minorTexts = dom.minorTexts;
dom.majorLines = []; dom.majorLines = [];
dom.majorTexts = []; dom.majorTexts = [];
dom.minorLines = []; dom.minorLines = [];
dom.minorTexts = []; dom.minorTexts = [];
};
/**
* End a repaint. Cleanup leftover DOM elements in the redundant list
* @private
*/
TimeAxis.prototype._repaintEnd = function () {
step.first();
var xFirstMajorLabel = undefined;
var max = 0;
while (step.hasNext() && max < 1000) {
max++;
var cur = step.getCurrent(),
x = this.options.toScreen(cur),
isMajor = step.isMajor();
// TODO: lines must have a width, such that we can create css backgrounds
if (this.getOption('showMinorLabels')) {
this._repaintMinorText(x, step.getLabelMinor(), orientation);
}
if (isMajor && this.getOption('showMajorLabels')) {
if (x > 0) {
if (xFirstMajorLabel == undefined) {
xFirstMajorLabel = x;
}
this._repaintMajorText(x, step.getLabelMajor(), orientation);
}
this._repaintMajorLine(x, orientation);
}
else {
this._repaintMinorLine(x, orientation);
}
step.next();
}
// create a major label on the left when needed
if (this.getOption('showMajorLabels')) {
var leftTime = this.options.toTime(0),
leftText = step.getLabelMajor(leftTime),
widthText = leftText.length * (this.props.majorCharWidth || 10) + 10; // upper bound estimation
if (xFirstMajorLabel == undefined || widthText < xFirstMajorLabel) {
this._repaintMajorText(0, leftText, orientation);
}
}
// Cleanup leftover DOM elements from the redundant list
util.forEach(this.dom.redundant, function (arr) { util.forEach(this.dom.redundant, function (arr) {
while (arr.length) { while (arr.length) {
var elem = arr.pop(); var elem = arr.pop();
@ -233,14 +227,14 @@ TimeAxis.prototype._repaintEnd = function () {
}); });
}; };
/** /**
* Create a minor label for the axis at position x * Create a minor label for the axis at position x
* @param {Number} x * @param {Number} x
* @param {String} text * @param {String} text
* @param {String} orientation "top" or "bottom" (default)
* @private * @private
*/ */
TimeAxis.prototype._repaintMinorText = function (x, text) {
TimeAxis.prototype._repaintMinorText = function (x, text, orientation) {
// reuse redundant label // reuse redundant label
var label = this.dom.redundant.minorTexts.shift(); var label = this.dom.redundant.minorTexts.shift();
@ -255,8 +249,16 @@ TimeAxis.prototype._repaintMinorText = function (x, text) {
this.dom.minorTexts.push(label); this.dom.minorTexts.push(label);
label.childNodes[0].nodeValue = text; label.childNodes[0].nodeValue = text;
if (orientation == 'top') {
label.style.top = this.props.majorLabelHeight + 'px';
label.style.bottom = '';
}
else {
label.style.top = '';
label.style.bottom = this.props.majorLabelHeight + 'px';
}
label.style.left = x + 'px'; label.style.left = x + 'px';
label.style.top = this.props.minorLabelTop + 'px';
//label.title = title; // TODO: this is a heavy operation //label.title = title; // TODO: this is a heavy operation
}; };
@ -264,9 +266,10 @@ TimeAxis.prototype._repaintMinorText = function (x, text) {
* Create a Major label for the axis at position x * Create a Major label for the axis at position x
* @param {Number} x * @param {Number} x
* @param {String} text * @param {String} text
* @param {String} orientation "top" or "bottom" (default)
* @private * @private
*/ */
TimeAxis.prototype._repaintMajorText = function (x, text) {
TimeAxis.prototype._repaintMajorText = function (x, text, orientation) {
// reuse redundant label // reuse redundant label
var label = this.dom.redundant.majorTexts.shift(); var label = this.dom.redundant.majorTexts.shift();
@ -281,17 +284,26 @@ TimeAxis.prototype._repaintMajorText = function (x, text) {
this.dom.majorTexts.push(label); this.dom.majorTexts.push(label);
label.childNodes[0].nodeValue = text; label.childNodes[0].nodeValue = text;
label.style.top = this.props.majorLabelTop + 'px';
label.style.left = x + 'px';
//label.title = title; // TODO: this is a heavy operation //label.title = title; // TODO: this is a heavy operation
if (orientation == 'top') {
label.style.top = '0px';
label.style.bottom = '';
}
else {
label.style.top = '';
label.style.bottom = '0px';
}
label.style.left = x + 'px';
}; };
/** /**
* Create a minor line for the axis at position x * Create a minor line for the axis at position x
* @param {Number} x * @param {Number} x
* @param {String} orientation "top" or "bottom" (default)
* @private * @private
*/ */
TimeAxis.prototype._repaintMinorLine = function (x) {
TimeAxis.prototype._repaintMinorLine = function (x, orientation) {
// reuse redundant line // reuse redundant line
var line = this.dom.redundant.minorLines.shift(); var line = this.dom.redundant.minorLines.shift();
@ -304,7 +316,14 @@ TimeAxis.prototype._repaintMinorLine = function (x) {
this.dom.minorLines.push(line); this.dom.minorLines.push(line);
var props = this.props; var props = this.props;
line.style.top = props.minorLineTop + 'px';
if (orientation == 'top') {
line.style.top = this.props.majorLabelHeight + 'px';
line.style.bottom = '';
}
else {
line.style.top = '';
line.style.bottom = this.props.majorLabelHeight + 'px';
}
line.style.height = props.minorLineHeight + 'px'; line.style.height = props.minorLineHeight + 'px';
line.style.left = (x - props.minorLineWidth / 2) + 'px'; line.style.left = (x - props.minorLineWidth / 2) + 'px';
}; };
@ -312,9 +331,10 @@ TimeAxis.prototype._repaintMinorLine = function (x) {
/** /**
* Create a Major line for the axis at position x * Create a Major line for the axis at position x
* @param {Number} x * @param {Number} x
* @param {String} orientation "top" or "bottom" (default)
* @private * @private
*/ */
TimeAxis.prototype._repaintMajorLine = function (x) {
TimeAxis.prototype._repaintMajorLine = function (x, orientation) {
// reuse redundant line // reuse redundant line
var line = this.dom.redundant.majorLines.shift(); var line = this.dom.redundant.majorLines.shift();
@ -327,7 +347,14 @@ TimeAxis.prototype._repaintMajorLine = function (x) {
this.dom.majorLines.push(line); this.dom.majorLines.push(line);
var props = this.props; var props = this.props;
line.style.top = props.majorLineTop + 'px';
if (orientation == 'top') {
line.style.top = '0px';
line.style.bottom = '';
}
else {
line.style.top = '';
line.style.bottom = '0px';
}
line.style.left = (x - props.majorLineWidth / 2) + 'px'; line.style.left = (x - props.majorLineWidth / 2) + 'px';
line.style.height = props.majorLineHeight + 'px'; line.style.height = props.majorLineHeight + 'px';
}; };
@ -340,7 +367,7 @@ TimeAxis.prototype._repaintMajorLine = function (x) {
TimeAxis.prototype._repaintLine = function() { TimeAxis.prototype._repaintLine = function() {
var line = this.dom.line, var line = this.dom.line,
frame = this.frame, frame = this.frame,
options = this.options;
orientation = this.getOption('orientation');
// line before all axis elements // line before all axis elements
if (this.getOption('showMinorLabels') || this.getOption('showMajorLabels')) { if (this.getOption('showMinorLabels') || this.getOption('showMajorLabels')) {
@ -357,167 +384,54 @@ TimeAxis.prototype._repaintLine = function() {
this.dom.line = line; this.dom.line = line;
} }
line.style.top = this.props.lineTop + 'px';
if (orientation == 'top') {
line.style.top = this.height + 'px';
line.style.bottom = '';
}
else {
line.style.top = '';
line.style.bottom = this.height + 'px';
}
} }
else { else {
if (line && line.parentElement) {
frame.removeChild(line.line);
if (line && line.parentNode) {
line.parentNode.removeChild(line);
delete this.dom.line; delete this.dom.line;
} }
} }
}; };
/** /**
* Create characters used to determine the size of text on the axis
* Determine the size of text on the axis (both major and minor axis).
* The size is calculated only once and then cached in this.props.
* @private * @private
*/ */
TimeAxis.prototype._repaintMeasureChars = function () {
// calculate the width and height of a single character
// this is used to calculate the step size, and also the positioning of the
// axis
var dom = this.dom,
text;
if (!dom.measureCharMinor) {
text = document.createTextNode('0');
TimeAxis.prototype._calculateCharSize = function () {
// determine the char width and height on the minor axis
if (!('minorCharHeight' in this.props)) {
var textMinor = document.createTextNode('0');
var measureCharMinor = document.createElement('DIV'); var measureCharMinor = document.createElement('DIV');
measureCharMinor.className = 'text minor measure'; measureCharMinor.className = 'text minor measure';
measureCharMinor.appendChild(text);
measureCharMinor.appendChild(textMinor);
this.frame.appendChild(measureCharMinor); this.frame.appendChild(measureCharMinor);
dom.measureCharMinor = measureCharMinor;
this.props.minorCharHeight = measureCharMinor.clientHeight;
this.props.minorCharWidth = measureCharMinor.clientWidth;
this.frame.removeChild(measureCharMinor);
} }
if (!dom.measureCharMajor) {
text = document.createTextNode('0');
if (!('majorCharHeight' in this.props)) {
var textMajor = document.createTextNode('0');
var measureCharMajor = document.createElement('DIV'); var measureCharMajor = document.createElement('DIV');
measureCharMajor.className = 'text major measure'; measureCharMajor.className = 'text major measure';
measureCharMajor.appendChild(text);
measureCharMajor.appendChild(textMajor);
this.frame.appendChild(measureCharMajor); this.frame.appendChild(measureCharMajor);
dom.measureCharMajor = measureCharMajor;
}
};
/**
* Reflow the component
* @return {Boolean} resized
*/
TimeAxis.prototype.reflow = function () {
var changed = 0,
update = util.updateProperty,
frame = this.frame,
range = this.range;
if (!range) {
throw new Error('Cannot repaint time axis: no range configured');
}
if (frame) {
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
this.props.majorCharHeight = measureCharMajor.clientHeight;
this.props.majorCharWidth = measureCharMajor.clientWidth;
// calculate size of a character
var props = this.props,
showMinorLabels = this.getOption('showMinorLabels'),
showMajorLabels = this.getOption('showMajorLabels'),
measureCharMinor = this.dom.measureCharMinor,
measureCharMajor = this.dom.measureCharMajor;
if (measureCharMinor) {
props.minorCharHeight = measureCharMinor.clientHeight;
props.minorCharWidth = measureCharMinor.clientWidth;
}
if (measureCharMajor) {
props.majorCharHeight = measureCharMajor.clientHeight;
props.majorCharWidth = measureCharMajor.clientWidth;
}
var parentHeight = frame.parentNode ? frame.parentNode.offsetHeight : 0;
if (parentHeight != props.parentHeight) {
props.parentHeight = parentHeight;
changed += 1;
}
switch (this.getOption('orientation')) {
case 'bottom':
props.minorLabelHeight = showMinorLabels ? props.minorCharHeight : 0;
props.majorLabelHeight = showMajorLabels ? props.majorCharHeight : 0;
props.minorLabelTop = 0;
props.majorLabelTop = props.minorLabelTop + props.minorLabelHeight;
props.minorLineTop = -this.top;
props.minorLineHeight = Math.max(this.top + props.majorLabelHeight, 0);
props.minorLineWidth = 1; // TODO: really calculate width
props.majorLineTop = -this.top;
props.majorLineHeight = Math.max(this.top + props.minorLabelHeight + props.majorLabelHeight, 0);
props.majorLineWidth = 1; // TODO: really calculate width
props.lineTop = 0;
break;
case 'top':
props.minorLabelHeight = showMinorLabels ? props.minorCharHeight : 0;
props.majorLabelHeight = showMajorLabels ? props.majorCharHeight : 0;
props.majorLabelTop = 0;
props.minorLabelTop = props.majorLabelTop + props.majorLabelHeight;
props.minorLineTop = props.minorLabelTop;
props.minorLineHeight = Math.max(parentHeight - props.majorLabelHeight - this.top);
props.minorLineWidth = 1; // TODO: really calculate width
props.majorLineTop = 0;
props.majorLineHeight = Math.max(parentHeight - this.top);
props.majorLineWidth = 1; // TODO: really calculate width
props.lineTop = props.majorLabelHeight + props.minorLabelHeight;
break;
default:
throw new Error('Unkown orientation "' + this.getOption('orientation') + '"');
}
var height = props.minorLabelHeight + props.majorLabelHeight;
changed += update(this, 'width', frame.offsetWidth);
changed += update(this, 'height', height);
// calculate range and step
this._updateConversion();
var start = util.convert(range.start, 'Number'),
end = util.convert(range.end, 'Number'),
minimumStep = this.toTime((props.minorCharWidth || 10) * 5).valueOf()
-this.toTime(0).valueOf();
this.step = new TimeStep(new Date(start), new Date(end), minimumStep);
changed += update(props.range, 'start', start);
changed += update(props.range, 'end', end);
changed += update(props.range, 'minimumStep', minimumStep.valueOf());
}
return (changed > 0);
};
/**
* Calculate the scale and offset to convert a position on screen to the
* corresponding date and vice versa.
* After the method _updateConversion is executed once, the methods toTime
* and toScreen can be used.
* @private
*/
TimeAxis.prototype._updateConversion = function() {
var range = this.range;
if (!range) {
throw new Error('No range configured');
}
if (range.conversion) {
this.conversion = range.conversion(this.width);
}
else {
this.conversion = Range.conversion(range.start, range.end, this.width);
this.frame.removeChild(measureCharMajor);
} }
}; };

+ 16
- 32
src/timeline/component/css/groupset.css View File

@ -1,59 +1,43 @@
.vis.timeline .groupset { .vis.timeline .groupset {
position: absolute;
padding: 0;
margin: 0;
position: relative;
} }
.vis.timeline .labels {
position: absolute;
top: 0;
left: 0;
.vis.timeline .labelset {
position: relative;
width: 100%; width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
border-right: 1px solid #bfbfbf;
-moz-box-sizing: border-box; -moz-box-sizing: border-box;
box-sizing: border-box; box-sizing: border-box;
} }
.vis.timeline .labels .label-set {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
border-top: none;
border-bottom: 1px solid #bfbfbf;
}
.vis.timeline .labels .label-set .vlabel {
position: absolute;
.vis.timeline .labelset .vlabel {
position: relative;
left: 0; left: 0;
top: 0; top: 0;
width: 100%; width: 100%;
color: #4d4d4d; color: #4d4d4d;
-moz-box-sizing: border-box;
box-sizing: border-box;
} }
.vis.timeline.top .labels .label-set .vlabel,
.vis.timeline.top .groupset .itemset-axis {
.vis.timeline.bottom .labelset .vlabel,
.vis.timeline.top .vpanel.side-content,
.vis.timeline.top .groupset .itemset {
border-top: 1px solid #bfbfbf; border-top: 1px solid #bfbfbf;
border-bottom: none; border-bottom: none;
} }
.vis.timeline.bottom .labels .label-set .vlabel,
.vis.timeline.bottom .groupset .itemset-axis {
.vis.timeline.top .labelset .vlabel,
.vis.timeline.bottom .vpanel.side-content,
.vis.timeline.bottom .groupset .itemset {
border-top: none; border-top: none;
border-bottom: 1px solid #bfbfbf; border-bottom: 1px solid #bfbfbf;
} }
.vis.timeline .labels .label-set .vlabel .inner {
.vis.timeline .labelset .vlabel .inner {
display: inline-block; display: inline-block;
padding: 5px; padding: 5px;
} }

+ 12
- 1
src/timeline/component/css/item.css View File

@ -6,6 +6,11 @@
background-color: #D5DDF6; background-color: #D5DDF6;
display: inline-block; display: inline-block;
padding: 5px; padding: 5px;
/* TODO: enable css transitions
-webkit-transition: top .4s ease-in-out, bottom .4s ease-in-out;
transition: top .4s ease-in-out, bottom .4s ease-in-out;
/**/
} }
.vis.timeline .item.selected { .vis.timeline .item.selected {
@ -22,7 +27,8 @@
background-color: #FFF785; background-color: #FFF785;
z-index: 999; z-index: 999;
} }
.vis.timeline .item.point.selected .dot {
.vis.timeline .item.point.selected .dot,
.vis.timeline .item.dot.selected {
border-color: #FFC200; border-color: #FFC200;
} }
@ -83,6 +89,11 @@
width: 0; width: 0;
border-left-width: 1px; border-left-width: 1px;
border-left-style: solid; border-left-style: solid;
/* TODO: enable css transitions
-webkit-transition: height .4s ease-in-out, top .4s ease-in-out;
transition: height .4s ease-in-out, top .4s ease-in-out;
/**/
} }
.vis.timeline .item .content { .vis.timeline .item .content {

+ 11
- 4
src/timeline/component/css/itemset.css View File

@ -1,9 +1,16 @@
.vis.timeline .itemset { .vis.timeline .itemset {
position: absolute;
position: relative;
padding: 0; padding: 0;
margin: 0; margin: 0;
overflow: hidden;
-moz-box-sizing: border-box;
box-sizing: border-box;
/* FIXME: get transition working for rootpanel and itemset
-webkit-transition: height 4s ease-in-out;
transition: height 4s ease-in-out;
/**/
} }
.vis.timeline .background { .vis.timeline .background {
@ -12,6 +19,6 @@
.vis.timeline .foreground { .vis.timeline .foreground {
} }
.vis.timeline .itemset-axis {
position: absolute;
.vis.timeline .axis {
overflow: visible;
} }

+ 16
- 0
src/timeline/component/css/panel.css View File

@ -6,9 +6,25 @@
border: 1px solid #bfbfbf; border: 1px solid #bfbfbf;
-moz-box-sizing: border-box; -moz-box-sizing: border-box;
box-sizing: border-box; box-sizing: border-box;
/* FIXME: there is an issue with the height of the items when panel height is animated
-webkit-transition: height 4s ease-in-out;
transition: height 4s ease-in-out;
/**/
} }
.vis.timeline .vpanel { .vis.timeline .vpanel {
position: absolute; position: absolute;
overflow: hidden; overflow: hidden;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.vis.timeline .vpanel.side {
border-right: 1px solid #bfbfbf;
}
.vis.timeline .vpanel.side.hidden {
display: none;
} }

+ 8
- 8
src/timeline/component/css/timeaxis.css View File

@ -1,15 +1,15 @@
.vis.timeline .axis {
position: relative;
.vis.timeline .timeaxis {
position: absolute;
} }
.vis.timeline .axis .text {
.vis.timeline .timeaxis .text {
position: absolute; position: absolute;
color: #4d4d4d; color: #4d4d4d;
padding: 3px; padding: 3px;
white-space: nowrap; white-space: nowrap;
} }
.vis.timeline .axis .text.measure {
.vis.timeline .timeaxis .text.measure {
position: absolute; position: absolute;
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
@ -18,13 +18,13 @@
visibility: hidden; visibility: hidden;
} }
.vis.timeline .axis .grid.vertical {
.vis.timeline .timeaxis .grid.vertical {
position: absolute; position: absolute;
width: 0; width: 0;
border-right: 1px solid; border-right: 1px solid;
} }
.vis.timeline .axis .grid.horizontal {
.vis.timeline .timeaxis .grid.horizontal {
position: absolute; position: absolute;
left: 0; left: 0;
width: 100%; width: 100%;
@ -32,10 +32,10 @@
border-bottom: 1px solid; border-bottom: 1px solid;
} }
.vis.timeline .axis .grid.minor {
.vis.timeline .timeaxis .grid.minor {
border-color: #e5e5e5; border-color: #e5e5e5;
} }
.vis.timeline .axis .grid.major {
.vis.timeline .timeaxis .grid.major {
border-color: #bfbfbf; border-color: #bfbfbf;
} }

+ 14
- 18
src/timeline/component/item/Item.js View File

@ -15,12 +15,13 @@ function Item (parent, data, options, defaultOptions) {
this.defaultOptions = defaultOptions || {}; this.defaultOptions = defaultOptions || {};
this.selected = false; this.selected = false;
this.visible = false;
this.top = 0;
this.left = 0;
this.width = 0;
this.height = 0;
this.offset = 0;
this.displayed = false;
this.dirty = true;
this.top = null;
this.left = null;
this.width = null;
this.height = null;
} }
/** /**
@ -28,7 +29,7 @@ function Item (parent, data, options, defaultOptions) {
*/ */
Item.prototype.select = function select() { Item.prototype.select = function select() {
this.selected = true; this.selected = true;
if (this.visible) this.repaint();
if (this.displayed) this.repaint();
}; };
/** /**
@ -36,7 +37,7 @@ Item.prototype.select = function select() {
*/ */
Item.prototype.unselect = function unselect() { Item.prototype.unselect = function unselect() {
this.selected = false; this.selected = false;
if (this.visible) this.repaint();
if (this.displayed) this.repaint();
}; };
/** /**
@ -57,28 +58,23 @@ Item.prototype.hide = function hide() {
/** /**
* Repaint the item * Repaint the item
* @return {Boolean} changed
*/ */
Item.prototype.repaint = function repaint() { Item.prototype.repaint = function repaint() {
// should be implemented by the item // should be implemented by the item
return false;
}; };
/** /**
* Reflow the item
* @return {Boolean} resized
* Reposition the Item horizontally
*/ */
Item.prototype.reflow = function reflow() {
Item.prototype.repositionX = function repositionX() {
// should be implemented by the item // should be implemented by the item
return false;
}; };
/** /**
* Give the item a display offset in pixels
* @param {Number} offset Offset on screen in pixels
* Reposition the Item vertically
*/ */
Item.prototype.setOffset = function setOffset(offset) {
this.offset = offset;
Item.prototype.repositionY = function repositionY() {
// should be implemented by the item
}; };
/** /**

+ 153
- 226
src/timeline/component/item/ItemBox.js View File

@ -11,294 +11,221 @@
function ItemBox (parent, data, options, defaultOptions) { function ItemBox (parent, data, options, defaultOptions) {
this.props = { this.props = {
dot: { dot: {
left: 0,
top: 0,
width: 0, width: 0,
height: 0 height: 0
}, },
line: { line: {
top: 0,
left: 0,
width: 0, width: 0,
height: 0 height: 0
} }
}; };
// validate data
if (data) {
if (data.start == undefined) {
throw new Error('Property "start" missing in item ' + data);
}
}
Item.call(this, parent, data, options, defaultOptions); Item.call(this, parent, data, options, defaultOptions);
} }
ItemBox.prototype = new Item (null, null); ItemBox.prototype = new Item (null, null);
/**
* Check whether this item is visible inside given range
* @returns {{start: Number, end: Number}} range with a timestamp for start and end
* @returns {boolean} True if visible
*/
ItemBox.prototype.isVisible = function isVisible (range) {
// determine visibility
// TODO: account for the real width of the item. Right now we just add 1/4 to the window
var interval = (range.end - range.start) / 4;
return (this.data.start > range.start - interval) && (this.data.start < range.end + interval);
};
/** /**
* Repaint the item * Repaint the item
* @return {Boolean} changed
*/ */
ItemBox.prototype.repaint = function repaint() { ItemBox.prototype.repaint = function repaint() {
// TODO: make an efficient repaint
var changed = false;
var dom = this.dom; var dom = this.dom;
if (!dom) { if (!dom) {
this._create();
// create DOM
this.dom = {};
dom = this.dom; dom = this.dom;
changed = true;
// create main box
dom.box = document.createElement('DIV');
// contents box (inside the background box). used for making margins
dom.content = document.createElement('DIV');
dom.content.className = 'content';
dom.box.appendChild(dom.content);
// line to axis
dom.line = document.createElement('DIV');
dom.line.className = 'line';
// dot on axis
dom.dot = document.createElement('DIV');
dom.dot.className = 'dot';
// attach this item as attribute
dom.box['timeline-item'] = this;
} }
if (dom) {
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
// append DOM to parent DOM
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
if (!dom.box.parentNode) {
var foreground = this.parent.getForeground();
if (!foreground) throw new Error('Cannot repaint time axis: parent has no foreground container element');
foreground.appendChild(dom.box);
}
if (!dom.line.parentNode) {
var background = this.parent.getBackground();
if (!background) throw new Error('Cannot repaint time axis: parent has no background container element');
background.appendChild(dom.line);
}
if (!dom.dot.parentNode) {
var axis = this.parent.getAxis();
if (!background) throw new Error('Cannot repaint time axis: parent has no axis container element');
axis.appendChild(dom.dot);
}
this.displayed = true;
// update contents
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
} }
if (!dom.box.parentNode) {
var foreground = this.parent.getForeground();
if (!foreground) {
throw new Error('Cannot repaint time axis: ' +
'parent has no foreground container element');
}
foreground.appendChild(dom.box);
changed = true;
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
} }
if (!dom.line.parentNode) {
var background = this.parent.getBackground();
if (!background) {
throw new Error('Cannot repaint time axis: ' +
'parent has no background container element');
}
background.appendChild(dom.line);
changed = true;
else {
throw new Error('Property "content" missing in item ' + this.data.id);
} }
if (!dom.dot.parentNode) {
var axis = this.parent.getAxis();
if (!background) {
throw new Error('Cannot repaint time axis: ' +
'parent has no axis container element');
}
axis.appendChild(dom.dot);
changed = true;
}
this.dirty = true;
}
this._repaintDeleteButton(dom.box);
// update contents
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
}
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
}
else {
throw new Error('Property "content" missing in item ' + this.data.id);
}
changed = true;
}
// update class
var className = (this.data.className? ' ' + this.data.className : '') +
(this.selected ? ' selected' : '');
if (this.className != className) {
this.className = className;
dom.box.className = 'item box' + className;
dom.line.className = 'item line' + className;
dom.dot.className = 'item dot' + className;
// update class
var className = (this.data.className? ' ' + this.data.className : '') +
(this.selected ? ' selected' : '');
if (this.className != className) {
this.className = className;
dom.box.className = 'item box' + className;
dom.line.className = 'item line' + className;
dom.dot.className = 'item dot' + className;
changed = true;
}
this.dirty = true;
} }
return changed;
// recalculate size
if (this.dirty) {
this.props.dot.height = dom.dot.offsetHeight;
this.props.dot.width = dom.dot.offsetWidth;
this.props.line.width = dom.line.offsetWidth;
this.width = dom.box.offsetWidth;
this.height = dom.box.offsetHeight;
this.dirty = false;
}
this._repaintDeleteButton(dom.box);
}; };
/** /**
* Show the item in the DOM (when not already visible). The items DOM will
* Show the item in the DOM (when not already displayed). The items DOM will
* be created when needed. * be created when needed.
* @return {Boolean} changed
*/ */
ItemBox.prototype.show = function show() { ItemBox.prototype.show = function show() {
if (!this.dom || !this.dom.box.parentNode) {
return this.repaint();
}
else {
return false;
if (!this.displayed) {
this.repaint();
} }
}; };
/** /**
* Hide the item from the DOM (when visible) * Hide the item from the DOM (when visible)
* @return {Boolean} changed
*/ */
ItemBox.prototype.hide = function hide() { ItemBox.prototype.hide = function hide() {
var changed = false,
dom = this.dom;
if (dom) {
if (dom.box.parentNode) {
dom.box.parentNode.removeChild(dom.box);
changed = true;
}
if (dom.line.parentNode) {
dom.line.parentNode.removeChild(dom.line);
}
if (dom.dot.parentNode) {
dom.dot.parentNode.removeChild(dom.dot);
}
if (this.displayed) {
var dom = this.dom;
if (dom.box.parentNode) dom.box.parentNode.removeChild(dom.box);
if (dom.line.parentNode) dom.line.parentNode.removeChild(dom.line);
if (dom.dot.parentNode) dom.dot.parentNode.removeChild(dom.dot);
this.top = null;
this.left = null;
this.displayed = false;
} }
return changed;
}; };
/** /**
* Reflow the item: calculate its actual size and position from the DOM
* @return {boolean} resized returns true if the axis is resized
* @override
* Reposition the item horizontally
* @Override
*/ */
ItemBox.prototype.reflow = function reflow() {
var changed = 0,
update,
dom,
props,
options,
margin,
start,
align,
orientation,
top,
ItemBox.prototype.repositionX = function repositionX() {
var start = this.defaultOptions.toScreen(this.data.start),
align = this.options.align || this.defaultOptions.align,
left, left,
data,
range;
box = this.dom.box,
line = this.dom.line,
dot = this.dom.dot;
if (this.data.start == undefined) {
throw new Error('Property "start" missing in item ' + this.data.id);
// calculate left position of the box
if (align == 'right') {
this.left = start - this.width;
} }
data = this.data;
range = this.parent && this.parent.range;
if (data && range) {
// TODO: account for the width of the item
var interval = (range.end - range.start);
this.visible = (data.start > range.start - interval) && (data.start < range.end + interval);
else if (align == 'left') {
this.left = start;
} }
else { else {
this.visible = false;
// default or 'center'
this.left = start - this.width / 2;
} }
if (this.visible) {
dom = this.dom;
if (dom) {
update = util.updateProperty;
props = this.props;
options = this.options;
start = this.parent.toScreen(this.data.start) + this.offset;
align = options.align || this.defaultOptions.align;
margin = options.margin && options.margin.axis || this.defaultOptions.margin.axis;
orientation = options.orientation || this.defaultOptions.orientation;
changed += update(props.dot, 'height', dom.dot.offsetHeight);
changed += update(props.dot, 'width', dom.dot.offsetWidth);
changed += update(props.line, 'width', dom.line.offsetWidth);
changed += update(props.line, 'height', dom.line.offsetHeight);
changed += update(props.line, 'top', dom.line.offsetTop);
changed += update(this, 'width', dom.box.offsetWidth);
changed += update(this, 'height', dom.box.offsetHeight);
if (align == 'right') {
left = start - this.width;
}
else if (align == 'left') {
left = start;
}
else {
// default or 'center'
left = start - this.width / 2;
}
changed += update(this, 'left', left);
changed += update(props.line, 'left', start - props.line.width / 2);
changed += update(props.dot, 'left', start - props.dot.width / 2);
changed += update(props.dot, 'top', -props.dot.height / 2);
if (orientation == 'top') {
top = margin;
changed += update(this, 'top', top);
}
else {
// default or 'bottom'
var parentHeight = this.parent.height;
top = parentHeight - this.height - margin;
changed += update(this, 'top', top);
}
}
else {
changed += 1;
}
}
return (changed > 0);
};
/**
* Create an items DOM
* @private
*/
ItemBox.prototype._create = function _create() {
var dom = this.dom;
if (!dom) {
this.dom = dom = {};
// reposition box
box.style.left = this.left + 'px';
// create the box
dom.box = document.createElement('DIV');
// className is updated in repaint()
// reposition line
line.style.left = (start - this.props.line.width / 2) + 'px';
// contents box (inside the background box). used for making margins
dom.content = document.createElement('DIV');
dom.content.className = 'content';
dom.box.appendChild(dom.content);
// line to axis
dom.line = document.createElement('DIV');
dom.line.className = 'line';
// dot on axis
dom.dot = document.createElement('DIV');
dom.dot.className = 'dot';
// attach this item as attribute
dom.box['timeline-item'] = this;
}
// reposition dot
dot.style.left = (start - this.props.dot.width / 2) + 'px';
}; };
/** /**
* Reposition the item, recalculate its left, top, and width, using the current
* range and size of the items itemset
* @override
* Reposition the item vertically
* @Override
*/ */
ItemBox.prototype.reposition = function reposition() {
var dom = this.dom,
props = this.props,
orientation = this.options.orientation || this.defaultOptions.orientation;
if (dom) {
var box = dom.box,
line = dom.line,
dot = dom.dot;
box.style.left = this.left + 'px';
box.style.top = this.top + 'px';
line.style.left = props.line.left + 'px';
if (orientation == 'top') {
line.style.top = 0 + 'px';
line.style.height = this.top + 'px';
}
else {
// orientation 'bottom'
line.style.top = (this.top + this.height) + 'px';
line.style.height = Math.max(this.parent.height - this.top - this.height +
this.props.dot.height / 2, 0) + 'px';
}
ItemBox.prototype.repositionY = function repositionY () {
var orientation = this.options.orientation || this.defaultOptions.orientation,
box = this.dom.box,
line = this.dom.line,
dot = this.dom.dot;
if (orientation == 'top') {
box.style.top = (this.top || 0) + 'px';
box.style.bottom = '';
line.style.top = '0';
line.style.bottom = '';
line.style.height = (this.parent.top + this.top + 1) + 'px';
}
else { // orientation 'bottom'
box.style.top = '';
box.style.bottom = (this.top || 0) + 'px';
dot.style.left = props.dot.left + 'px';
dot.style.top = props.dot.top + 'px';
line.style.top = (this.parent.top + this.parent.height - this.top - 1) + 'px';
line.style.bottom = '0';
line.style.height = '';
} }
dot.style.top = (-this.props.dot.height / 2) + 'px';
}; };

+ 114
- 163
src/timeline/component/item/ItemPoint.js View File

@ -21,219 +21,170 @@ function ItemPoint (parent, data, options, defaultOptions) {
} }
}; };
// validate data
if (data) {
if (data.start == undefined) {
throw new Error('Property "start" missing in item ' + data);
}
}
Item.call(this, parent, data, options, defaultOptions); Item.call(this, parent, data, options, defaultOptions);
} }
ItemPoint.prototype = new Item (null, null); ItemPoint.prototype = new Item (null, null);
/**
* Check whether this item is visible inside given range
* @returns {{start: Number, end: Number}} range with a timestamp for start and end
* @returns {boolean} True if visible
*/
ItemPoint.prototype.isVisible = function isVisible (range) {
// determine visibility
var interval = (range.end - range.start);
return (this.data.start > range.start - interval) && (this.data.start < range.end);
}
/** /**
* Repaint the item * Repaint the item
* @return {Boolean} changed
*/ */
ItemPoint.prototype.repaint = function repaint() { ItemPoint.prototype.repaint = function repaint() {
// TODO: make an efficient repaint
var changed = false;
var dom = this.dom; var dom = this.dom;
if (!dom) { if (!dom) {
this._create();
// create DOM
this.dom = {};
dom = this.dom; dom = this.dom;
changed = true;
// background box
dom.point = document.createElement('div');
// className is updated in repaint()
// contents box, right from the dot
dom.content = document.createElement('div');
dom.content.className = 'content';
dom.point.appendChild(dom.content);
// dot at start
dom.dot = document.createElement('div');
dom.dot.className = 'dot';
dom.point.appendChild(dom.dot);
// attach this item as attribute
dom.point['timeline-item'] = this;
} }
if (dom) {
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
// append DOM to parent DOM
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
if (!dom.point.parentNode) {
var foreground = this.parent.getForeground(); var foreground = this.parent.getForeground();
if (!foreground) { if (!foreground) {
throw new Error('Cannot repaint time axis: ' +
'parent has no foreground container element');
throw new Error('Cannot repaint time axis: parent has no foreground container element');
} }
if (!dom.point.parentNode) {
foreground.appendChild(dom.point);
foreground.appendChild(dom.point);
changed = true;
foreground.appendChild(dom.point);
}
this.displayed = true;
// update contents
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
} }
// update contents
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
}
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
}
else {
throw new Error('Property "content" missing in item ' + this.data.id);
}
changed = true;
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
}
else {
throw new Error('Property "content" missing in item ' + this.data.id);
} }
this._repaintDeleteButton(dom.point);
this.dirty = true;
}
// update class
var className = (this.data.className? ' ' + this.data.className : '') +
(this.selected ? ' selected' : '');
if (this.className != className) {
this.className = className;
dom.point.className = 'item point' + className;
changed = true;
}
// update class
var className = (this.data.className? ' ' + this.data.className : '') +
(this.selected ? ' selected' : '');
if (this.className != className) {
this.className = className;
dom.point.className = 'item point' + className;
this.dirty = true;
}
// recalculate size
if (this.dirty) {
this.width = dom.point.offsetWidth;
this.height = dom.point.offsetHeight;
this.props.dot.width = dom.dot.offsetWidth;
this.props.dot.height = dom.dot.offsetHeight;
this.props.content.height = dom.content.offsetHeight;
// resize contents
dom.content.style.marginLeft = 1.5 * this.props.dot.width + 'px';
//dom.content.style.marginRight = ... + 'px'; // TODO: margin right
dom.dot.style.top = ((this.height - this.props.dot.height) / 2) + 'px';
this.dirty = false;
} }
return changed;
this._repaintDeleteButton(dom.point);
}; };
/** /**
* Show the item in the DOM (when not already visible). The items DOM will * Show the item in the DOM (when not already visible). The items DOM will
* be created when needed. * be created when needed.
* @return {Boolean} changed
*/ */
ItemPoint.prototype.show = function show() { ItemPoint.prototype.show = function show() {
if (!this.dom || !this.dom.point.parentNode) {
return this.repaint();
}
else {
return false;
if (!this.displayed) {
this.repaint();
} }
}; };
/** /**
* Hide the item from the DOM (when visible) * Hide the item from the DOM (when visible)
* @return {Boolean} changed
*/ */
ItemPoint.prototype.hide = function hide() { ItemPoint.prototype.hide = function hide() {
var changed = false,
dom = this.dom;
if (dom) {
if (dom.point.parentNode) {
dom.point.parentNode.removeChild(dom.point);
changed = true;
if (this.displayed) {
if (this.dom.point.parentNode) {
this.dom.point.parentNode.removeChild(this.dom.point);
} }
}
return changed;
};
/**
* Reflow the item: calculate its actual size from the DOM
* @return {boolean} resized returns true if the axis is resized
* @override
*/
ItemPoint.prototype.reflow = function reflow() {
var changed = 0,
update,
dom,
props,
options,
margin,
orientation,
start,
top,
data,
range;
if (this.data.start == undefined) {
throw new Error('Property "start" missing in item ' + this.data.id);
}
data = this.data;
range = this.parent && this.parent.range;
if (data && range) {
// TODO: account for the width of the item
var interval = (range.end - range.start);
this.visible = (data.start > range.start - interval) && (data.start < range.end);
}
else {
this.visible = false;
}
this.top = null;
this.left = null;
if (this.visible) {
dom = this.dom;
if (dom) {
update = util.updateProperty;
props = this.props;
options = this.options;
orientation = options.orientation || this.defaultOptions.orientation;
margin = options.margin && options.margin.axis || this.defaultOptions.margin.axis;
start = this.parent.toScreen(this.data.start) + this.offset;
changed += update(this, 'width', dom.point.offsetWidth);
changed += update(this, 'height', dom.point.offsetHeight);
changed += update(props.dot, 'width', dom.dot.offsetWidth);
changed += update(props.dot, 'height', dom.dot.offsetHeight);
changed += update(props.content, 'height', dom.content.offsetHeight);
if (orientation == 'top') {
top = margin;
}
else {
// default or 'bottom'
var parentHeight = this.parent.height;
top = Math.max(parentHeight - this.height - margin, 0);
}
changed += update(this, 'top', top);
changed += update(this, 'left', start - props.dot.width / 2);
changed += update(props.content, 'marginLeft', 1.5 * props.dot.width);
//changed += update(props.content, 'marginRight', 0.5 * props.dot.width); // TODO
changed += update(props.dot, 'top', (this.height - props.dot.height) / 2);
}
else {
changed += 1;
}
this.displayed = false;
} }
return (changed > 0);
}; };
/** /**
* Create an items DOM
* @private
* Reposition the item horizontally
* @Override
*/ */
ItemPoint.prototype._create = function _create() {
var dom = this.dom;
if (!dom) {
this.dom = dom = {};
// background box
dom.point = document.createElement('div');
// className is updated in repaint()
// contents box, right from the dot
dom.content = document.createElement('div');
dom.content.className = 'content';
dom.point.appendChild(dom.content);
ItemPoint.prototype.repositionX = function repositionX() {
var start = this.defaultOptions.toScreen(this.data.start);
// dot at start
dom.dot = document.createElement('div');
dom.dot.className = 'dot';
dom.point.appendChild(dom.dot);
this.left = start - this.props.dot.width / 2;
// attach this item as attribute
dom.point['timeline-item'] = this;
}
// reposition point
this.dom.point.style.left = this.left + 'px';
}; };
/** /**
* Reposition the item, recalculate its left, top, and width, using the current
* range and size of the items itemset
* @override
* Reposition the item vertically
* @Override
*/ */
ItemPoint.prototype.reposition = function reposition() {
var dom = this.dom,
props = this.props;
if (dom) {
dom.point.style.top = this.top + 'px';
dom.point.style.left = this.left + 'px';
ItemPoint.prototype.repositionY = function repositionY () {
var orientation = this.options.orientation || this.defaultOptions.orientation,
point = this.dom.point;
dom.content.style.marginLeft = props.content.marginLeft + 'px';
//dom.content.style.marginRight = props.content.marginRight + 'px'; // TODO
dom.dot.style.top = props.dot.top + 'px';
if (orientation == 'top') {
point.style.top = this.top + 'px';
point.style.bottom = '';
} }
};
else {
point.style.top = '';
point.style.bottom = this.top + 'px';
}
}

+ 128
- 179
src/timeline/component/item/ItemRange.js View File

@ -11,90 +11,120 @@
function ItemRange (parent, data, options, defaultOptions) { function ItemRange (parent, data, options, defaultOptions) {
this.props = { this.props = {
content: { content: {
left: 0,
width: 0 width: 0
} }
}; };
// validate data
if (data) {
if (data.start == undefined) {
throw new Error('Property "start" missing in item ' + data.id);
}
if (data.end == undefined) {
throw new Error('Property "end" missing in item ' + data.id);
}
}
Item.call(this, parent, data, options, defaultOptions); Item.call(this, parent, data, options, defaultOptions);
} }
ItemRange.prototype = new Item (null, null); ItemRange.prototype = new Item (null, null);
ItemRange.prototype.baseClassName = 'item range';
/**
* Check whether this item is visible inside given range
* @returns {{start: Number, end: Number}} range with a timestamp for start and end
* @returns {boolean} True if visible
*/
ItemRange.prototype.isVisible = function isVisible (range) {
// determine visibility
return (this.data.start < range.end) && (this.data.end > range.start);
};
/** /**
* Repaint the item * Repaint the item
* @return {Boolean} changed
*/ */
ItemRange.prototype.repaint = function repaint() { ItemRange.prototype.repaint = function repaint() {
// TODO: make an efficient repaint
var changed = false;
var dom = this.dom; var dom = this.dom;
if (!dom) { if (!dom) {
this._create();
// create DOM
this.dom = {};
dom = this.dom; dom = this.dom;
changed = true;
// background box
dom.box = document.createElement('div');
// className is updated in repaint()
// contents box
dom.content = document.createElement('div');
dom.content.className = 'content';
dom.box.appendChild(dom.content);
// attach this item as attribute
dom.box['timeline-item'] = this;
} }
if (dom) {
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
// append DOM to parent DOM
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
if (!dom.box.parentNode) {
var foreground = this.parent.getForeground(); var foreground = this.parent.getForeground();
if (!foreground) { if (!foreground) {
throw new Error('Cannot repaint time axis: ' +
'parent has no foreground container element');
throw new Error('Cannot repaint time axis: parent has no foreground container element');
} }
if (!dom.box.parentNode) {
foreground.appendChild(dom.box);
changed = true;
foreground.appendChild(dom.box);
}
this.displayed = true;
// update contents
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
} }
// update content
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
}
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
}
else {
throw new Error('Property "content" missing in item ' + this.data.id);
}
changed = true;
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
} }
this._repaintDeleteButton(dom.box);
this._repaintDragLeft();
this._repaintDragRight();
// update class
var className = (this.data.className ? (' ' + this.data.className) : '') +
(this.selected ? ' selected' : '');
if (this.className != className) {
this.className = className;
dom.box.className = 'item range' + className;
changed = true;
else {
throw new Error('Property "content" missing in item ' + this.data.id);
} }
this.dirty = true;
}
// update class
var className = (this.data.className ? (' ' + this.data.className) : '') +
(this.selected ? ' selected' : '');
if (this.className != className) {
this.className = className;
dom.box.className = this.baseClassName + className;
this.dirty = true;
} }
return changed;
// recalculate size
if (this.dirty) {
this.props.content.width = this.dom.content.offsetWidth;
this.height = this.dom.box.offsetHeight;
this.dirty = false;
}
this._repaintDeleteButton(dom.box);
this._repaintDragLeft();
this._repaintDragRight();
}; };
/** /**
* Show the item in the DOM (when not already visible). The items DOM will * Show the item in the DOM (when not already visible). The items DOM will
* be created when needed. * be created when needed.
* @return {Boolean} changed
*/ */
ItemRange.prototype.show = function show() { ItemRange.prototype.show = function show() {
if (!this.dom || !this.dom.box.parentNode) {
return this.repaint();
}
else {
return false;
if (!this.displayed) {
this.repaint();
} }
}; };
@ -103,154 +133,73 @@ ItemRange.prototype.show = function show() {
* @return {Boolean} changed * @return {Boolean} changed
*/ */
ItemRange.prototype.hide = function hide() { ItemRange.prototype.hide = function hide() {
var changed = false,
dom = this.dom;
if (dom) {
if (dom.box.parentNode) {
dom.box.parentNode.removeChild(dom.box);
changed = true;
if (this.displayed) {
var box = this.dom.box;
if (box.parentNode) {
box.parentNode.removeChild(box);
} }
this.top = null;
this.left = null;
this.displayed = false;
} }
return changed;
}; };
/** /**
* Reflow the item: calculate its actual size from the DOM
* @return {boolean} resized returns true if the axis is resized
* @override
* Reposition the item horizontally
* @Override
*/ */
ItemRange.prototype.reflow = function reflow() {
var changed = 0,
dom,
props,
options,
margin,
padding,
parent,
start,
end,
data,
range,
update,
box,
parentWidth,
contentLeft,
orientation,
top;
if (this.data.start == undefined) {
throw new Error('Property "start" missing in item ' + this.data.id);
ItemRange.prototype.repositionX = function repositionX() {
var props = this.props,
parentWidth = this.parent.width,
start = this.defaultOptions.toScreen(this.data.start),
end = this.defaultOptions.toScreen(this.data.end),
padding = 'padding' in this.options ? this.options.padding : this.defaultOptions.padding,
contentLeft;
// limit the width of the this, as browsers cannot draw very wide divs
if (start < -parentWidth) {
start = -parentWidth;
} }
if (this.data.end == undefined) {
throw new Error('Property "end" missing in item ' + this.data.id);
if (end > 2 * parentWidth) {
end = 2 * parentWidth;
} }
data = this.data;
range = this.parent && this.parent.range;
if (data && range) {
// TODO: account for the width of the item. Take some margin
this.visible = (data.start < range.end) && (data.end > range.start);
// when range exceeds left of the window, position the contents at the left of the visible area
if (start < 0) {
contentLeft = Math.min(-start,
(end - start - props.content.width - 2 * padding));
// TODO: remove the need for options.padding. it's terrible.
} }
else { else {
this.visible = false;
contentLeft = 0;
} }
if (this.visible) {
dom = this.dom;
if (dom) {
props = this.props;
options = this.options;
parent = this.parent;
start = parent.toScreen(this.data.start) + this.offset;
end = parent.toScreen(this.data.end) + this.offset;
update = util.updateProperty;
box = dom.box;
parentWidth = parent.width;
orientation = options.orientation || this.defaultOptions.orientation;
margin = options.margin && options.margin.axis || this.defaultOptions.margin.axis;
padding = options.padding || this.defaultOptions.padding;
changed += update(props.content, 'width', dom.content.offsetWidth);
changed += update(this, 'height', box.offsetHeight);
// limit the width of the this, as browsers cannot draw very wide divs
if (start < -parentWidth) {
start = -parentWidth;
}
if (end > 2 * parentWidth) {
end = 2 * parentWidth;
}
// when range exceeds left of the window, position the contents at the left of the visible area
if (start < 0) {
contentLeft = Math.min(-start,
(end - start - props.content.width - 2 * padding));
// TODO: remove the need for options.padding. it's terrible.
}
else {
contentLeft = 0;
}
changed += update(props.content, 'left', contentLeft);
if (orientation == 'top') {
top = margin;
changed += update(this, 'top', top);
}
else {
// default or 'bottom'
top = parent.height - this.height - margin;
changed += update(this, 'top', top);
}
changed += update(this, 'left', start);
changed += update(this, 'width', Math.max(end - start, 1)); // TODO: reckon with border width;
}
else {
changed += 1;
}
}
this.left = start;
this.width = Math.max(end - start, 1);
return (changed > 0);
this.dom.box.style.left = this.left + 'px';
this.dom.box.style.width = this.width + 'px';
this.dom.content.style.left = contentLeft + 'px';
}; };
/** /**
* Create an items DOM
* @private
* Reposition the item vertically
* @Override
*/ */
ItemRange.prototype._create = function _create() {
var dom = this.dom;
if (!dom) {
this.dom = dom = {};
// background box
dom.box = document.createElement('div');
// className is updated in repaint()
// contents box
dom.content = document.createElement('div');
dom.content.className = 'content';
dom.box.appendChild(dom.content);
ItemRange.prototype.repositionY = function repositionY() {
var orientation = this.options.orientation || this.defaultOptions.orientation,
box = this.dom.box;
// attach this item as attribute
dom.box['timeline-item'] = this;
if (orientation == 'top') {
box.style.top = this.top + 'px';
box.style.bottom = '';
} }
};
/**
* Reposition the item, recalculate its left, top, and width, using the current
* range and size of the items itemset
* @override
*/
ItemRange.prototype.reposition = function reposition() {
var dom = this.dom,
props = this.props;
if (dom) {
dom.box.style.top = this.top + 'px';
dom.box.style.left = this.left + 'px';
dom.box.style.width = this.width + 'px';
dom.content.style.left = props.content.left + 'px';
else {
box.style.top = '';
box.style.bottom = this.top + 'px';
} }
}; };

+ 26
- 88
src/timeline/component/item/ItemRangeOverflow.js View File

@ -16,104 +16,42 @@ function ItemRangeOverflow (parent, data, options, defaultOptions) {
} }
}; };
// define a private property _width, which is the with of the range box
// adhering to the ranges start and end date. The property width has a
// getter which returns the max of border width and content width
this._width = 0;
Object.defineProperty(this, 'width', {
get: function () {
return (this.props.content && this._width < this.props.content.width) ?
this.props.content.width :
this._width;
},
set: function (width) {
this._width = width;
}
});
ItemRange.call(this, parent, data, options, defaultOptions); ItemRange.call(this, parent, data, options, defaultOptions);
} }
ItemRangeOverflow.prototype = new ItemRange (null, null); ItemRangeOverflow.prototype = new ItemRange (null, null);
ItemRangeOverflow.prototype.baseClassName = 'item rangeoverflow';
/** /**
* Repaint the item
* @return {Boolean} changed
* Reposition the item horizontally
* @Override
*/ */
ItemRangeOverflow.prototype.repaint = function repaint() {
// TODO: make an efficient repaint
var changed = false;
var dom = this.dom;
if (!dom) {
this._create();
dom = this.dom;
changed = true;
ItemRangeOverflow.prototype.repositionX = function repositionX() {
var parentWidth = this.parent.width,
start = this.defaultOptions.toScreen(this.data.start),
end = this.defaultOptions.toScreen(this.data.end),
padding = 'padding' in this.options ? this.options.padding : this.defaultOptions.padding,
contentLeft;
// limit the width of the this, as browsers cannot draw very wide divs
if (start < -parentWidth) {
start = -parentWidth;
} }
if (dom) {
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
var foreground = this.parent.getForeground();
if (!foreground) {
throw new Error('Cannot repaint time axis: ' +
'parent has no foreground container element');
}
if (!dom.box.parentNode) {
foreground.appendChild(dom.box);
changed = true;
}
// update content
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
}
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
}
else {
throw new Error('Property "content" missing in item ' + this.id);
}
changed = true;
}
this._repaintDeleteButton(dom.box);
this._repaintDragLeft();
this._repaintDragRight();
// update class
var className = (this.data.className? ' ' + this.data.className : '') +
(this.selected ? ' selected' : '');
if (this.className != className) {
this.className = className;
dom.box.className = 'item rangeoverflow' + className;
changed = true;
}
if (end > 2 * parentWidth) {
end = 2 * parentWidth;
} }
return changed;
};
// when range exceeds left of the window, position the contents at the left of the visible area
contentLeft = Math.max(-start, 0);
/**
* Reposition the item, recalculate its left, top, and width, using the current
* range and size of the items itemset
* @override
*/
ItemRangeOverflow.prototype.reposition = function reposition() {
var dom = this.dom,
props = this.props;
this.left = start;
var boxWidth = Math.max(end - start, 1);
this.width = (this.props.content.width < boxWidth) ?
boxWidth :
start + contentLeft + this.props.content.width;
if (dom) {
dom.box.style.top = this.top + 'px';
dom.box.style.left = this.left + 'px';
dom.box.style.width = this._width + 'px';
dom.content.style.left = props.content.left + 'px';
}
this.dom.box.style.left = this.left + 'px';
this.dom.box.style.width = boxWidth + 'px';
this.dom.content.style.left = contentLeft + 'px';
}; };

+ 42
- 3
src/util.js View File

@ -97,6 +97,23 @@ util.extend = function (a, b) {
return a; return a;
}; };
/**
* Test whether all elements in two arrays are equal.
* @param {Array} a
* @param {Array} b
* @return {boolean} Returns true if both arrays have the same length and same
* elements.
*/
util.equalArray = function (a, b) {
if (a.length != b.length) return false;
for (var i = 1, len = a.length; i < len; i++) {
if (a[i] != b[i]) return false;
}
return true;
};
/** /**
* Convert an object to another type * Convert an object to another type
* @param {Boolean | Number | String | Date | Moment | Null | undefined} object * @param {Boolean | Number | String | Date | Moment | Null | undefined} object
@ -440,6 +457,22 @@ util.forEach = function forEach (object, callback) {
} }
}; };
/**
* Convert an object into an array: all objects properties are put into the
* array. The resulting array is unordered.
* @param {Object} object
* @param {Array} array
*/
util.toArray = function toArray(object) {
var array = [];
for (var prop in object) {
if (object.hasOwnProperty(prop)) array.push(object[prop]);
}
return array;
}
/** /**
* Update a property in an object * Update a property in an object
* @param {Object} object * @param {Object} object
@ -447,7 +480,7 @@ util.forEach = function forEach (object, callback) {
* @param {*} value * @param {*} value
* @return {Boolean} changed * @return {Boolean} changed
*/ */
util.updateProperty = function updateProp (object, key, value) {
util.updateProperty = function updateProperty (object, key, value) {
if (object[key] !== value) { if (object[key] !== value) {
object[key] = value; object[key] = value;
return true; return true;
@ -655,6 +688,8 @@ util.option.asElement = function (value, defaultValue) {
util.GiveDec = function GiveDec(Hex) { util.GiveDec = function GiveDec(Hex) {
var Value;
if (Hex == "A") if (Hex == "A")
Value = 10; Value = 10;
else if (Hex == "B") else if (Hex == "B")
@ -668,12 +703,15 @@ util.GiveDec = function GiveDec(Hex) {
else if (Hex == "F") else if (Hex == "F")
Value = 15; Value = 15;
else else
Value = eval(Hex)
Value = eval(Hex);
return Value; return Value;
}; };
util.GiveHex = function GiveHex(Dec) { util.GiveHex = function GiveHex(Dec) {
if (Dec == 10)
var Value;
if(Dec == 10)
Value = "A"; Value = "A";
else if (Dec == 11) else if (Dec == 11)
Value = "B"; Value = "B";
@ -687,6 +725,7 @@ util.GiveHex = function GiveHex(Dec) {
Value = "F"; Value = "F";
else else
Value = "" + Dec; Value = "" + Dec;
return Value; return Value;
}; };

+ 15
- 0
test/dataset.js View File

@ -162,5 +162,20 @@ assert.deepEqual((data.get()[0].id == undefined), false);
assert.deepEqual(data.isInternalId(data.get()[0].id), true); assert.deepEqual(data.isInternalId(data.get()[0].id), true);
assert.deepEqual((data.get({"showInternalIds": false})[0].id == undefined),true); assert.deepEqual((data.get({"showInternalIds": false})[0].id == undefined),true);
// create a dataset with initial data
var data = new DataSet([
{id: 1, content: 'Item 1', start: new Date(now.valueOf())},
{id: 2, content: 'Item 2', start: now.toISOString()}
]);
assert.deepEqual(data.getIds(), [1, 2]);
// create a dataset with initial data and options
var data = new DataSet([
{_id: 1, content: 'Item 1', start: new Date(now.valueOf())},
{_id: 2, content: 'Item 2', start: now.toISOString()}
], {fieldId: '_id'});
assert.deepEqual(data.getIds(), [1, 2]);
// TODO: extensively test DataSet // TODO: extensively test DataSet
// TODO: test subscribing to events // TODO: test subscribing to events

+ 41
- 2
test/timeline.html View File

@ -35,11 +35,26 @@
}); });
}; };
</script> </script>
<div>
<label for="currenttime"><input id="currenttime" type="checkbox" checked="true"> Show current time</label>
</div>
<script>
var currenttime = document.getElementById('currenttime');
currenttime.onchange = function () {
timeline.setOptions({
showCurrentTime: currenttime.checked
});
};
</script>
<br> <br>
<div id="visualization"></div> <div id="visualization"></div>
<script> <script>
console.time('create dataset');
// create a dataset with items // create a dataset with items
var now = moment().minutes(0).seconds(0).milliseconds(0); var now = moment().minutes(0).seconds(0).milliseconds(0);
var items = new vis.DataSet({ var items = new vis.DataSet({
@ -63,20 +78,44 @@
var container = document.getElementById('visualization'); var container = document.getElementById('visualization');
var options = { var options = {
editable: true,
//orientation: 'top', //orientation: 'top',
start: now.clone().add('days', -7), start: now.clone().add('days', -7),
end: now.clone().add('days', 7), end: now.clone().add('days', 7),
//maxHeight: 200, //maxHeight: 200,
height: 200,
//height: 200,
showCurrentTime: true,
showCustomTime: true,
//start: moment('2013-01-01'), //start: moment('2013-01-01'),
//end: moment('2013-12-31'), //end: moment('2013-12-31'),
//min: moment('2013-01-01'), //min: moment('2013-01-01'),
//max: moment('2013-12-31'), //max: moment('2013-12-31'),
zoomMin: 1000 * 60 * 60 * 24, // 1 day
//zoomMin: 1000 * 60 * 60 * 24, // 1 day
zoomMax: 1000 * 60 * 60 * 24 * 30 * 6 // 6 months zoomMax: 1000 * 60 * 60 * 24 * 30 * 6 // 6 months
}; };
console.timeEnd('create dataset');
console.time('create timeline');
var timeline = new vis.Timeline(container, items, options); var timeline = new vis.Timeline(container, items, options);
console.timeEnd('create timeline');
timeline.on('select', function (selection) {
console.log('select', selection);
});
/*
timeline.on('rangechange', function (range) {
console.log('rangechange', range);
});
timeline.on('rangechanged', function (range) {
console.log('rangechanged', range);
});
*/
items.on('add', console.log.bind(console));
items.on('update', console.log.bind(console));
items.on('remove', console.log.bind(console));
</script> </script>
</body> </body>

+ 33
- 0
test/timeline_groups.html View File

@ -73,14 +73,47 @@
// create visualization // create visualization
var container = document.getElementById('visualization'); var container = document.getElementById('visualization');
var options = { var options = {
editable: true,
//height: 200, //height: 200,
groupOrder: 'content' groupOrder: 'content'
}; };
console.time('create timeline');
var timeline = new vis.Timeline(container); var timeline = new vis.Timeline(container);
console.timeEnd('create timeline');
console.time('set options');
timeline.setOptions(options); timeline.setOptions(options);
console.timeEnd('set options');
console.time('set groups');
timeline.setGroups(groups); timeline.setGroups(groups);
console.timeEnd('set groups');
console.time('set items');
timeline.setItems(items); timeline.setItems(items);
console.timeEnd('set items');
timeline.on('select', function (selection) {
console.log('select', selection);
});
/*
timeline.on('rangechange', function (range) {
console.log('rangechange', range);
});
timeline.on('rangechanged', function (range) {
console.log('rangechanged', range);
});
*/
items.on('add', console.log.bind(console));
items.on('update', console.log.bind(console));
items.on('remove', console.log.bind(console));
groups.on('add', console.log.bind(console));
groups.on('update', console.log.bind(console));
groups.on('remove', console.log.bind(console));
</script> </script>
</body> </body>

Loading…
Cancel
Save