@ -0,0 +1,2 @@ | |||
.idea | |||
node_modules |
@ -0,0 +1,114 @@ | |||
/** | |||
* Jake build script | |||
*/ | |||
var jake = require('jake'), | |||
fs = require('fs'), | |||
path = require('path'); | |||
require('jake-utils'); | |||
/** | |||
* default task | |||
*/ | |||
desc('Execute all tasks: build all libraries'); | |||
task('default', ['timeline'], function () { | |||
console.log('done'); | |||
}); | |||
/** | |||
* timeline | |||
*/ | |||
desc('Build the timeline visualization'); | |||
task('timeline', function () { | |||
var TIMELINE = './bin/timeline/timeline.js'; | |||
var TIMELINE_MIN = './bin/timeline/timeline.min.js'; | |||
var DIR = './bin/timeline'; | |||
jake.rmRf(DIR); | |||
jake.mkdirP(DIR); | |||
// concatenate the script files | |||
concat({ | |||
dest: TIMELINE, | |||
src: [ | |||
'./src/header.js', | |||
'./src/util.js', | |||
'./src/events.js', | |||
'./src/timestep.js', | |||
'./src/dataset.js', | |||
'./src/stack.js', | |||
'./src/range.js', | |||
'./src/controller.js', | |||
'./src/component/component.js', | |||
'./src/component/panel.js', | |||
'./src/component/rootpanel.js', | |||
'./src/component/timeaxis.js', | |||
'./src/component/itemset.js', | |||
'./src/component/item/*.js', | |||
'./src/visualization/timeline.js', | |||
'./lib/moment.js' | |||
], | |||
separator: '\n' | |||
}); | |||
// concatenate the css files | |||
concat({ | |||
dest: './bin/timeline/timeline.css', | |||
src: [ | |||
'./src/component/css/panel.css', | |||
'./src/component/css/item.css', | |||
'./src/component/css/timeaxis.css' | |||
], | |||
separator: '\n' | |||
}); | |||
// minify javascript | |||
minify({ | |||
src: TIMELINE, | |||
dest: TIMELINE_MIN, | |||
header: read('./src/header.js') | |||
}); | |||
// update version number and stuff in the javascript files | |||
[TIMELINE, TIMELINE_MIN].forEach(function (file) { | |||
replace({ | |||
replacements: [ | |||
{pattern: '@@name', replacement: 'timeline'}, | |||
{pattern: '@@date', replacement: today()}, | |||
{pattern: '@@version', replacement: version()} | |||
], | |||
src: file | |||
}); | |||
}); | |||
// copy examples | |||
jake.cpR('./src/examples/timeline', './bin/timeline/examples/'); | |||
console.log('created timeline library'); | |||
}); | |||
/** | |||
* Recursively remove a directory and its files | |||
* https://gist.github.com/tkihira/2367067 | |||
* @param {String} dir | |||
*/ | |||
var rmdir = function(dir) { | |||
var list = fs.readdirSync(dir); | |||
for(var i = 0; i < list.length; i++) { | |||
var filename = path.join(dir, list[i]); | |||
var stat = fs.statSync(filename); | |||
if(filename == "." || filename == "..") { | |||
// pass these files | |||
} else if(stat.isDirectory()) { | |||
// rmdir recursively | |||
rmdir(filename); | |||
} else { | |||
// rm fiilename | |||
fs.unlinkSync(filename); | |||
} | |||
} | |||
fs.rmdirSync(dir); | |||
}; |
@ -0,0 +1,176 @@ | |||
Apache License | |||
Version 2.0, January 2004 | |||
http://www.apache.org/licenses/ | |||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | |||
1. Definitions. | |||
"License" shall mean the terms and conditions for use, reproduction, | |||
and distribution as defined by Sections 1 through 9 of this document. | |||
"Licensor" shall mean the copyright owner or entity authorized by | |||
the copyright owner that is granting the License. | |||
"Legal Entity" shall mean the union of the acting entity and all | |||
other entities that control, are controlled by, or are under common | |||
control with that entity. For the purposes of this definition, | |||
"control" means (i) the power, direct or indirect, to cause the | |||
direction or management of such entity, whether by contract or | |||
otherwise, or (ii) ownership of fifty percent (50%) or more of the | |||
outstanding shares, or (iii) beneficial ownership of such entity. | |||
"You" (or "Your") shall mean an individual or Legal Entity | |||
exercising permissions granted by this License. | |||
"Source" form shall mean the preferred form for making modifications, | |||
including but not limited to software source code, documentation | |||
source, and configuration files. | |||
"Object" form shall mean any form resulting from mechanical | |||
transformation or translation of a Source form, including but | |||
not limited to compiled object code, generated documentation, | |||
and conversions to other media types. | |||
"Work" shall mean the work of authorship, whether in Source or | |||
Object form, made available under the License, as indicated by a | |||
copyright notice that is included in or attached to the work | |||
(an example is provided in the Appendix below). | |||
"Derivative Works" shall mean any work, whether in Source or Object | |||
form, that is based on (or derived from) the Work and for which the | |||
editorial revisions, annotations, elaborations, or other modifications | |||
represent, as a whole, an original work of authorship. For the purposes | |||
of this License, Derivative Works shall not include works that remain | |||
separable from, or merely link (or bind by name) to the interfaces of, | |||
the Work and Derivative Works thereof. | |||
"Contribution" shall mean any work of authorship, including | |||
the original version of the Work and any modifications or additions | |||
to that Work or Derivative Works thereof, that is intentionally | |||
submitted to Licensor for inclusion in the Work by the copyright owner | |||
or by an individual or Legal Entity authorized to submit on behalf of | |||
the copyright owner. For the purposes of this definition, "submitted" | |||
means any form of electronic, verbal, or written communication sent | |||
to the Licensor or its representatives, including but not limited to | |||
communication on electronic mailing lists, source code control systems, | |||
and issue tracking systems that are managed by, or on behalf of, the | |||
Licensor for the purpose of discussing and improving the Work, but | |||
excluding communication that is conspicuously marked or otherwise | |||
designated in writing by the copyright owner as "Not a Contribution." | |||
"Contributor" shall mean Licensor and any individual or Legal Entity | |||
on behalf of whom a Contribution has been received by Licensor and | |||
subsequently incorporated within the Work. | |||
2. Grant of Copyright License. Subject to the terms and conditions of | |||
this License, each Contributor hereby grants to You a perpetual, | |||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |||
copyright license to reproduce, prepare Derivative Works of, | |||
publicly display, publicly perform, sublicense, and distribute the | |||
Work and such Derivative Works in Source or Object form. | |||
3. Grant of Patent License. Subject to the terms and conditions of | |||
this License, each Contributor hereby grants to You a perpetual, | |||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |||
(except as stated in this section) patent license to make, have made, | |||
use, offer to sell, sell, import, and otherwise transfer the Work, | |||
where such license applies only to those patent claims licensable | |||
by such Contributor that are necessarily infringed by their | |||
Contribution(s) alone or by combination of their Contribution(s) | |||
with the Work to which such Contribution(s) was submitted. If You | |||
institute patent litigation against any entity (including a | |||
cross-claim or counterclaim in a lawsuit) alleging that the Work | |||
or a Contribution incorporated within the Work constitutes direct | |||
or contributory patent infringement, then any patent licenses | |||
granted to You under this License for that Work shall terminate | |||
as of the date such litigation is filed. | |||
4. Redistribution. You may reproduce and distribute copies of the | |||
Work or Derivative Works thereof in any medium, with or without | |||
modifications, and in Source or Object form, provided that You | |||
meet the following conditions: | |||
(a) You must give any other recipients of the Work or | |||
Derivative Works a copy of this License; and | |||
(b) You must cause any modified files to carry prominent notices | |||
stating that You changed the files; and | |||
(c) You must retain, in the Source form of any Derivative Works | |||
that You distribute, all copyright, patent, trademark, and | |||
attribution notices from the Source form of the Work, | |||
excluding those notices that do not pertain to any part of | |||
the Derivative Works; and | |||
(d) If the Work includes a "NOTICE" text file as part of its | |||
distribution, then any Derivative Works that You distribute must | |||
include a readable copy of the attribution notices contained | |||
within such NOTICE file, excluding those notices that do not | |||
pertain to any part of the Derivative Works, in at least one | |||
of the following places: within a NOTICE text file distributed | |||
as part of the Derivative Works; within the Source form or | |||
documentation, if provided along with the Derivative Works; or, | |||
within a display generated by the Derivative Works, if and | |||
wherever such third-party notices normally appear. The contents | |||
of the NOTICE file are for informational purposes only and | |||
do not modify the License. You may add Your own attribution | |||
notices within Derivative Works that You distribute, alongside | |||
or as an addendum to the NOTICE text from the Work, provided | |||
that such additional attribution notices cannot be construed | |||
as modifying the License. | |||
You may add Your own copyright statement to Your modifications and | |||
may provide additional or different license terms and conditions | |||
for use, reproduction, or distribution of Your modifications, or | |||
for any such Derivative Works as a whole, provided Your use, | |||
reproduction, and distribution of the Work otherwise complies with | |||
the conditions stated in this License. | |||
5. Submission of Contributions. Unless You explicitly state otherwise, | |||
any Contribution intentionally submitted for inclusion in the Work | |||
by You to the Licensor shall be under the terms and conditions of | |||
this License, without any additional terms or conditions. | |||
Notwithstanding the above, nothing herein shall supersede or modify | |||
the terms of any separate license agreement you may have executed | |||
with Licensor regarding such Contributions. | |||
6. Trademarks. This License does not grant permission to use the trade | |||
names, trademarks, service marks, or product names of the Licensor, | |||
except as required for reasonable and customary use in describing the | |||
origin of the Work and reproducing the content of the NOTICE file. | |||
7. Disclaimer of Warranty. Unless required by applicable law or | |||
agreed to in writing, Licensor provides the Work (and each | |||
Contributor provides its Contributions) on an "AS IS" BASIS, | |||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | |||
implied, including, without limitation, any warranties or conditions | |||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | |||
PARTICULAR PURPOSE. You are solely responsible for determining the | |||
appropriateness of using or redistributing the Work and assume any | |||
risks associated with Your exercise of permissions under this License. | |||
8. Limitation of Liability. In no event and under no legal theory, | |||
whether in tort (including negligence), contract, or otherwise, | |||
unless required by applicable law (such as deliberate and grossly | |||
negligent acts) or agreed to in writing, shall any Contributor be | |||
liable to You for damages, including any direct, indirect, special, | |||
incidental, or consequential damages of any character arising as a | |||
result of this License or out of the use or inability to use the | |||
Work (including but not limited to damages for loss of goodwill, | |||
work stoppage, computer failure or malfunction, or any and all | |||
other commercial damages or losses), even if such Contributor | |||
has been advised of the possibility of such damages. | |||
9. Accepting Warranty or Additional Liability. While redistributing | |||
the Work or Derivative Works thereof, You may choose to offer, | |||
and charge a fee for, acceptance of support, warranty, indemnity, | |||
or other liability obligations and/or rights consistent with this | |||
License. However, in accepting such obligations, You may act only | |||
on Your own behalf and on Your sole responsibility, not on behalf | |||
of any other Contributor, and only if You agree to indemnify, | |||
defend, and hold each Contributor harmless for any liability | |||
incurred by, or claims asserted against, such Contributor by reason | |||
of your accepting any such warranty or additional liability. | |||
END OF TERMS AND CONDITIONS |
@ -0,0 +1,14 @@ | |||
Vis.js | |||
Copyright 2010-2013 Almende B.V. | |||
Licensed under the Apache License, Version 2.0 (the "License"); | |||
you may not use this file except in compliance with the License. | |||
You may obtain a copy of the License at | |||
http://www.apache.org/licenses/LICENSE-2.0 | |||
Unless required by applicable law or agreed to in writing, software | |||
distributed under the License is distributed on an "AS IS" BASIS, | |||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
See the License for the specific language governing permissions and | |||
limitations under the License. |
@ -1,4 +1,10 @@ | |||
vis | |||
=== | |||
vis.js | |||
================== | |||
Dynamic, browser based visualization library | |||
Vis.js is a dynamic, browser based visualization library. | |||
The library is designed to be easy to use, to handle large amounts | |||
of dynamic data, and to enable manipulation of the data. | |||
The library consists of Timeline, LineChart, LineChart3d, Graph, and Treegrid. | |||
vis.js Library is part of [CHAP](http://chap.almende.com), | |||
the Common Hybrid Agent Platform, developed by [Almende B.V](http://almende.com). |
@ -0,0 +1,62 @@ | |||
<!DOCTYPE HTML> | |||
<html> | |||
<head> | |||
<title>Timeline demo</title> | |||
<script src="../timeline.js"></script> | |||
<link href="../timeline.css" rel="stylesheet" type="text/css" /> | |||
<style> | |||
body, html { | |||
width: 100%; | |||
height: 100%; | |||
padding: 0; | |||
margin: 0; | |||
font-family: arial, sans-serif; | |||
font-size: 12pt; | |||
} | |||
#visualization { | |||
box-sizing: border-box; | |||
padding: 10px; | |||
width: 100%; | |||
height: 300px; | |||
} | |||
#visualization .itemset { | |||
/*background: rgba(255, 255, 0, 0.5);*/ | |||
} | |||
</style> | |||
</head> | |||
<body> | |||
<div id="visualization"></div> | |||
<script> | |||
// create a dataset with items | |||
var now = moment().minutes(0).seconds(0).milliseconds(0); | |||
var data = new DataSet({ | |||
fieldTypes: { | |||
start: 'Date', | |||
end: 'Date' | |||
} | |||
}); | |||
data.add([ | |||
{id: 1, content: 'item 1<br>start', start: now.clone().add('days', 4).toDate()}, | |||
{id: 2, content: 'item 2', start: now.clone().add('days', -2).toDate() }, | |||
{id: 3, content: 'item 3', start: now.clone().add('days', 2).toDate()}, | |||
{id: 4, content: 'item 4', start: now.clone().add('days', 0).toDate(), end: now.clone().add('days', 3).toDate()}, | |||
{id: 5, content: 'item 5', start: now.clone().add('days', 9).toDate(), type:'point'}, | |||
{id: 6, content: 'item 6', start: now.clone().add('days', 11).toDate()} | |||
]); | |||
var container = document.getElementById('visualization'); | |||
var options = { | |||
start: now.clone().add('days', -3).valueOf(), | |||
end: now.clone().add('days', 7).valueOf() | |||
}; | |||
var timeline = new Timeline(container, data, options); | |||
</script> | |||
</body> | |||
</html> |
@ -0,0 +1,142 @@ | |||
.graph { | |||
position: relative; | |||
border: 1px solid #bfbfbf; | |||
} | |||
.graph .panel { | |||
position: absolute; | |||
} | |||
.graph .itemset { | |||
position: absolute; | |||
} | |||
.graph .item { | |||
position: absolute; | |||
color: #1A1A1A; | |||
border-color: #97B0F8; | |||
background-color: #D5DDF6; | |||
display: inline-block; | |||
} | |||
.graph .item.selected { | |||
border-color: #FFC200; | |||
background-color: #FFF785; | |||
z-index: 999; | |||
} | |||
.graph .item.cluster { | |||
/* TODO: use another color or pattern? */ | |||
background: #97B0F8 url('img/cluster_bg.png'); | |||
color: white; | |||
} | |||
.graph .item.cluster.point { | |||
border-color: #D5DDF6; | |||
} | |||
.graph .item.box { | |||
text-align: center; | |||
border-style: solid; | |||
border-width: 1px; | |||
border-radius: 5px; | |||
-moz-border-radius: 5px; /* For Firefox 3.6 and older */ | |||
} | |||
.graph .item.point { | |||
background: none; | |||
} | |||
.graph .dot { | |||
border: 5px solid #97B0F8; | |||
position: absolute; | |||
border-radius: 5px; | |||
-moz-border-radius: 5px; /* For Firefox 3.6 and older */ | |||
} | |||
.graph .item.range { | |||
overflow: hidden; | |||
border-style: solid; | |||
border-width: 1px; | |||
border-radius: 2px; | |||
-moz-border-radius: 2px; /* For Firefox 3.6 and older */ | |||
} | |||
.graph .item.range .drag-left { | |||
cursor: w-resize; | |||
z-index: 1000; | |||
} | |||
.graph .item.range .drag-right { | |||
cursor: e-resize; | |||
z-index: 1000; | |||
} | |||
.graph .item.range .content { | |||
position: relative; | |||
display: inline-block; | |||
} | |||
.graph .item.line { | |||
position: absolute; | |||
width: 0; | |||
border-left-width: 1px; | |||
border-left-style: solid; | |||
z-index: -1; | |||
} | |||
.graph .item .content { | |||
margin: 5px; | |||
white-space: nowrap; | |||
overflow: hidden; | |||
} | |||
/* TODO: better css name, 'graph' is way to generic */ | |||
.graph { | |||
overflow: hidden; | |||
} | |||
.graph .axis { | |||
position: relative; | |||
} | |||
.graph .axis .text { | |||
position: absolute; | |||
color: #4d4d4d; | |||
padding: 3px; | |||
white-space: nowrap; | |||
} | |||
.graph .axis .text.measure { | |||
position: absolute; | |||
padding-left: 0; | |||
padding-right: 0; | |||
margin-left: 0; | |||
margin-right: 0; | |||
visibility: hidden; | |||
} | |||
.graph .axis .grid.vertical { | |||
position: absolute; | |||
width: 0; | |||
border-right: 1px solid; | |||
} | |||
.graph .axis .grid.horizontal { | |||
position: absolute; | |||
left: 0; | |||
width: 100%; | |||
height: 0; | |||
border-bottom: 1px solid; | |||
} | |||
.graph .axis .grid.minor { | |||
border-color: #e5e5e5; | |||
} | |||
.graph .axis .grid.major { | |||
border-color: #bfbfbf; | |||
} | |||
@ -0,0 +1,30 @@ | |||
{ | |||
"name": "vis", | |||
"version": "3.0.0-SNAPSHOT", | |||
"description": "A dynamic, browser-based visualization library.", | |||
"homepage": "https://github.com/almende/vis", | |||
"repository": { | |||
"type": "git", | |||
"url": "git://github.com/almende/vis.git" | |||
}, | |||
"keywords": [ | |||
"visualization", | |||
"web based", | |||
"browser based", | |||
"chart", | |||
"linechart", | |||
"timeline", | |||
"graph", | |||
"network", | |||
"javascript", | |||
"browser" | |||
], | |||
"dependencies": {}, | |||
"devDependencies": { | |||
"jake": ">= 0.5.9", | |||
"jake-utils": ">= 0.0.18", | |||
"uglify-js": ">= 2.2.5", | |||
"nodeunit": ">= 0.7.4", | |||
"dateable": ">= 0.1.2" | |||
} | |||
} |
@ -0,0 +1,116 @@ | |||
/** | |||
* Prototype for visual components | |||
*/ | |||
function Component () { | |||
this.id = null; | |||
this.parent = null; | |||
this.depends = null; | |||
this.controller = null; | |||
this.options = null; | |||
this.frame = null; // main DOM element | |||
this.top = 0; | |||
this.left = 0; | |||
this.width = 0; | |||
this.height = 0; | |||
} | |||
/** | |||
* Set parameters for the frame. Parameters will be merged in current parameter | |||
* set. | |||
* @param {Object} options Available parameters: | |||
* {String | function} [className] | |||
* {String | Number | function} [left] | |||
* {String | Number | function} [top] | |||
* {String | Number | function} [width] | |||
* {String | Number | function} [height] | |||
*/ | |||
Component.prototype.setOptions = function(options) { | |||
if (options) { | |||
util.extend(this.options, options); | |||
} | |||
if (this.controller) { | |||
this.requestRepaint(); | |||
this.requestReflow(); | |||
} | |||
}; | |||
/** | |||
* 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 | |||
*/ | |||
Component.prototype.getContainer = function () { | |||
// should be implemented by the component | |||
return null; | |||
}; | |||
/** | |||
* Get the frame element of the component, the outer HTML DOM element. | |||
* @returns {HTMLElement | null} frame | |||
*/ | |||
Component.prototype.getFrame = function () { | |||
return this.frame; | |||
}; | |||
/** | |||
* Repaint the component | |||
* @return {Boolean} changed | |||
*/ | |||
Component.prototype.repaint = function () { | |||
// should be implemented by the component | |||
return false; | |||
}; | |||
/** | |||
* Reflow the component | |||
* @return {Boolean} resized | |||
*/ | |||
Component.prototype.reflow = function () { | |||
// should be implemented by the component | |||
return false; | |||
}; | |||
/** | |||
* Request a repaint. The controller will schedule a repaint | |||
*/ | |||
Component.prototype.requestRepaint = function () { | |||
if (this.controller) { | |||
this.controller.requestRepaint(); | |||
} | |||
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 () { | |||
if (this.controller) { | |||
this.controller.requestReflow(); | |||
} | |||
else { | |||
throw new Error('Cannot request a reflow: no controller configured'); | |||
// TODO: just do a reflow when no parent is configured? | |||
} | |||
}; | |||
/** | |||
* Event handler | |||
* @param {String} event name of the event, for example 'click', 'mousemove' | |||
* @param {function} callback callback handler, invoked with the raw HTML Event | |||
* as parameter. | |||
*/ | |||
Component.prototype.on = function (event, callback) { | |||
// TODO: rethink the way of event delegation | |||
if (this.parent) { | |||
this.parent.on(event, callback); | |||
} | |||
else { | |||
throw new Error('Cannot attach event: no root panel found'); | |||
} | |||
}; |
@ -0,0 +1,84 @@ | |||
.graph .itemset { | |||
position: absolute; | |||
} | |||
.graph .item { | |||
position: absolute; | |||
color: #1A1A1A; | |||
border-color: #97B0F8; | |||
background-color: #D5DDF6; | |||
display: inline-block; | |||
} | |||
.graph .item.selected { | |||
border-color: #FFC200; | |||
background-color: #FFF785; | |||
z-index: 999; | |||
} | |||
.graph .item.cluster { | |||
/* TODO: use another color or pattern? */ | |||
background: #97B0F8 url('img/cluster_bg.png'); | |||
color: white; | |||
} | |||
.graph .item.cluster.point { | |||
border-color: #D5DDF6; | |||
} | |||
.graph .item.box { | |||
text-align: center; | |||
border-style: solid; | |||
border-width: 1px; | |||
border-radius: 5px; | |||
-moz-border-radius: 5px; /* For Firefox 3.6 and older */ | |||
} | |||
.graph .item.point { | |||
background: none; | |||
} | |||
.graph .dot { | |||
border: 5px solid #97B0F8; | |||
position: absolute; | |||
border-radius: 5px; | |||
-moz-border-radius: 5px; /* For Firefox 3.6 and older */ | |||
} | |||
.graph .item.range { | |||
overflow: hidden; | |||
border-style: solid; | |||
border-width: 1px; | |||
border-radius: 2px; | |||
-moz-border-radius: 2px; /* For Firefox 3.6 and older */ | |||
} | |||
.graph .item.range .drag-left { | |||
cursor: w-resize; | |||
z-index: 1000; | |||
} | |||
.graph .item.range .drag-right { | |||
cursor: e-resize; | |||
z-index: 1000; | |||
} | |||
.graph .item.range .content { | |||
position: relative; | |||
display: inline-block; | |||
} | |||
.graph .item.line { | |||
position: absolute; | |||
width: 0; | |||
border-left-width: 1px; | |||
border-left-style: solid; | |||
z-index: -1; | |||
} | |||
.graph .item .content { | |||
margin: 5px; | |||
white-space: nowrap; | |||
overflow: hidden; | |||
} |
@ -0,0 +1,9 @@ | |||
.graph { | |||
position: relative; | |||
border: 1px solid #bfbfbf; | |||
} | |||
.graph .panel { | |||
position: absolute; | |||
} |
@ -0,0 +1,47 @@ | |||
/* TODO: better css name, 'graph' is way to generic */ | |||
.graph { | |||
overflow: hidden; | |||
} | |||
.graph .axis { | |||
position: relative; | |||
} | |||
.graph .axis .text { | |||
position: absolute; | |||
color: #4d4d4d; | |||
padding: 3px; | |||
white-space: nowrap; | |||
} | |||
.graph .axis .text.measure { | |||
position: absolute; | |||
padding-left: 0; | |||
padding-right: 0; | |||
margin-left: 0; | |||
margin-right: 0; | |||
visibility: hidden; | |||
} | |||
.graph .axis .grid.vertical { | |||
position: absolute; | |||
width: 0; | |||
border-right: 1px solid; | |||
} | |||
.graph .axis .grid.horizontal { | |||
position: absolute; | |||
left: 0; | |||
width: 100%; | |||
height: 0; | |||
border-bottom: 1px solid; | |||
} | |||
.graph .axis .grid.minor { | |||
border-color: #e5e5e5; | |||
} | |||
.graph .axis .grid.major { | |||
border-color: #bfbfbf; | |||
} |
@ -0,0 +1,36 @@ | |||
/** | |||
* @constructor Item | |||
* @param {ItemSet} parent | |||
* @param {Object} data Object containing (optional) parameters type, | |||
* start, end, content, group, className. | |||
* @param {Object} [options] Options to set initial property values | |||
* // TODO: describe available options | |||
*/ | |||
function Item (parent, data, options) { | |||
this.parent = parent; | |||
this.data = data; | |||
this.selected = false; | |||
this.visible = true; | |||
this.dom = null; | |||
this.options = options; | |||
} | |||
Item.prototype = new Component(); | |||
/** | |||
* Select current item | |||
*/ | |||
Item.prototype.select = function () { | |||
this.selected = true; | |||
}; | |||
/** | |||
* Unselect current item | |||
*/ | |||
Item.prototype.unselect = function () { | |||
this.selected = false; | |||
}; | |||
// create a namespace for all item types | |||
var itemTypes = {}; |
@ -0,0 +1,270 @@ | |||
/** | |||
* @constructor ItemBox | |||
* @extends Item | |||
* @param {ItemSet} parent | |||
* @param {Object} data Object containing parameters start | |||
* content, className. | |||
* @param {Object} [options] Options to set initial property values | |||
* // TODO: describe available options | |||
*/ | |||
function ItemBox (parent, data, options) { | |||
this.props = { | |||
dot: { | |||
left: 0, | |||
top: 0, | |||
width: 0, | |||
height: 0 | |||
}, | |||
line: { | |||
top: 0, | |||
left: 0, | |||
width: 0, | |||
height: 0 | |||
} | |||
}; | |||
Item.call(this, parent, data, options); | |||
} | |||
ItemBox.prototype = new Item (null, null); | |||
// register the ItemBox in the item types | |||
itemTypes['box'] = ItemBox; | |||
/** | |||
* Select the item | |||
* @override | |||
*/ | |||
ItemBox.prototype.select = function () { | |||
this.selected = true; | |||
// TODO: select and unselect | |||
}; | |||
/** | |||
* Unselect the item | |||
* @override | |||
*/ | |||
ItemBox.prototype.unselect = function () { | |||
this.selected = false; | |||
// TODO: select and unselect | |||
}; | |||
/** | |||
* Repaint the item | |||
* @return {Boolean} changed | |||
*/ | |||
ItemBox.prototype.repaint = function () { | |||
// TODO: make an efficient repaint | |||
var changed = false; | |||
var dom = this.dom; | |||
if (this.visible) { | |||
if (!dom) { | |||
this._create(); | |||
changed = true; | |||
} | |||
dom = this.dom; | |||
if (dom) { | |||
if (!this.options && !this.parent) { | |||
throw new Error('Cannot repaint item: no parent attached'); | |||
} | |||
var parentContainer = this.parent.getContainer(); | |||
if (!parentContainer) { | |||
throw new Error('Cannot repaint time axis: parent has no container element'); | |||
} | |||
if (!dom.box.parentNode) { | |||
parentContainer.appendChild(dom.box); | |||
changed = true; | |||
} | |||
if (!dom.line.parentNode) { | |||
parentContainer.appendChild(dom.line); | |||
changed = true; | |||
} | |||
if (!dom.dot.parentNode) { | |||
parentContainer.appendChild(dom.dot); | |||
changed = 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); | |||
} | |||
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; | |||
changed = true; | |||
} | |||
} | |||
} | |||
else { | |||
// hide when visible | |||
if (dom) { | |||
if (dom.box.parentNode) { | |||
dom.box.parentNode.removeChild(dom.box); | |||
changed = true; | |||
} | |||
if (dom.line.parentNode) { | |||
dom.line.parentNode.removeChild(dom.line); | |||
changed = true; | |||
} | |||
if (dom.dot.parentNode) { | |||
dom.dot.parentNode.removeChild(dom.dot); | |||
changed = true; | |||
} | |||
} | |||
} | |||
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 | |||
*/ | |||
ItemBox.prototype.reflow = function () { | |||
if (this.data.start == undefined) { | |||
throw new Error('Property "start" missing in item ' + this.data.id); | |||
} | |||
var update = util.updateProperty, | |||
dom = this.dom, | |||
props = this.props, | |||
options = this.options, | |||
start = this.parent.toScreen(this.data.start), | |||
align = options && options.align, | |||
orientation = options.orientation, | |||
changed = 0, | |||
top, | |||
left; | |||
if (dom) { | |||
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, 'width', dom.line.offsetWidth); | |||
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); | |||
if (orientation == 'top') { | |||
top = options.margin.axis; | |||
changed += update(this, 'top', top); | |||
changed += update(props.line, 'top', 0); | |||
changed += update(props.line, 'height', top); | |||
changed += update(props.dot, 'top', -props.dot.height / 2); | |||
} | |||
else { | |||
// default or 'bottom' | |||
var parentHeight = this.parent.height; | |||
top = parentHeight - this.height - options.margin.axis; | |||
changed += update(this, 'top', top); | |||
changed += update(props.line, 'top', top + this.height); | |||
changed += update(props.line, 'height', Math.max(options.margin.axis, 0)); | |||
changed += update(props.dot, 'top', parentHeight - props.dot.height / 2); | |||
} | |||
} | |||
else { | |||
changed += 1; | |||
} | |||
return (changed > 0); | |||
}; | |||
/** | |||
* Create an items DOM | |||
* @private | |||
*/ | |||
ItemBox.prototype._create = function () { | |||
var dom = this.dom; | |||
if (!dom) { | |||
this.dom = dom = {}; | |||
// create the box | |||
dom.box = document.createElement('DIV'); | |||
// className is updated in repaint() | |||
// 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'; | |||
} | |||
}; | |||
/** | |||
* Reposition the item, recalculate its left, top, and width, using the current | |||
* range and size of the items itemset | |||
* @override | |||
*/ | |||
ItemBox.prototype.reposition = function () { | |||
var dom = this.dom, | |||
props = this.props, | |||
orientation = this.options.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 = props.line.top + 'px'; | |||
line.style.top = (this.top + this.height) + 'px'; | |||
line.style.height = (props.dot.top - this.top - this.height) + 'px'; | |||
} | |||
dot.style.left = props.dot.left + 'px'; | |||
dot.style.top = props.dot.top + 'px'; | |||
} | |||
}; |
@ -0,0 +1,209 @@ | |||
/** | |||
* @constructor ItemPoint | |||
* @extends Item | |||
* @param {ItemSet} parent | |||
* @param {Object} data Object containing parameters start | |||
* content, className. | |||
* @param {Object} [options] Options to set initial property values | |||
* // TODO: describe available options | |||
*/ | |||
function ItemPoint (parent, data, options) { | |||
this.props = { | |||
dot: { | |||
top: 0, | |||
width: 0, | |||
height: 0 | |||
}, | |||
content: { | |||
height: 0, | |||
marginLeft: 0 | |||
} | |||
}; | |||
Item.call(this, parent, data, options); | |||
} | |||
ItemPoint.prototype = new Item (null, null); | |||
// register the ItemPoint in the item types | |||
itemTypes['point'] = ItemPoint; | |||
/** | |||
* Select the item | |||
* @override | |||
*/ | |||
ItemPoint.prototype.select = function () { | |||
this.selected = true; | |||
// TODO: select and unselect | |||
}; | |||
/** | |||
* Unselect the item | |||
* @override | |||
*/ | |||
ItemPoint.prototype.unselect = function () { | |||
this.selected = false; | |||
// TODO: select and unselect | |||
}; | |||
/** | |||
* Repaint the item | |||
* @return {Boolean} changed | |||
*/ | |||
ItemPoint.prototype.repaint = function () { | |||
// TODO: make an efficient repaint | |||
var changed = false; | |||
var dom = this.dom; | |||
if (this.visible) { | |||
if (!dom) { | |||
this._create(); | |||
changed = true; | |||
} | |||
dom = this.dom; | |||
if (dom) { | |||
if (!this.options && !this.options.parent) { | |||
throw new Error('Cannot repaint item: no parent attached'); | |||
} | |||
var parentContainer = this.parent.getContainer(); | |||
if (!parentContainer) { | |||
throw new Error('Cannot repaint time axis: parent has no container element'); | |||
} | |||
if (!dom.point.parentNode) { | |||
parentContainer.appendChild(dom.point); | |||
changed = 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); | |||
} | |||
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.point.className = 'item point' + className; | |||
changed = true; | |||
} | |||
} | |||
} | |||
else { | |||
// hide when visible | |||
if (dom) { | |||
if (dom.point.parentNode) { | |||
dom.point.parentNode.removeChild(dom.point); | |||
changed = true; | |||
} | |||
} | |||
} | |||
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 () { | |||
if (this.data.start == undefined) { | |||
throw new Error('Property "start" missing in item ' + this.data.id); | |||
} | |||
var update = util.updateProperty, | |||
dom = this.dom, | |||
props = this.props, | |||
options = this.options, | |||
orientation = options.orientation, | |||
start = this.parent.toScreen(this.data.start), | |||
changed = 0, | |||
top; | |||
if (dom) { | |||
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 = options.margin.axis; | |||
} | |||
else { | |||
// default or 'bottom' | |||
var parentHeight = this.parent.height; | |||
top = parentHeight - this.height - options.margin.axis; | |||
} | |||
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; | |||
} | |||
return (changed > 0); | |||
}; | |||
/** | |||
* Create an items DOM | |||
* @private | |||
*/ | |||
ItemPoint.prototype._create = function () { | |||
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); | |||
// dot at start | |||
dom.dot = document.createElement('div'); | |||
dom.dot.className = 'dot'; | |||
dom.point.appendChild(dom.dot); | |||
} | |||
}; | |||
/** | |||
* Reposition the item, recalculate its left, top, and width, using the current | |||
* range and size of the items itemset | |||
* @override | |||
*/ | |||
ItemPoint.prototype.reposition = function () { | |||
var dom = this.dom, | |||
props = this.props; | |||
if (dom) { | |||
dom.point.style.top = this.top + 'px'; | |||
dom.point.style.left = this.left + 'px'; | |||
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'; | |||
} | |||
}; |
@ -0,0 +1,219 @@ | |||
/** | |||
* @constructor ItemRange | |||
* @extends Item | |||
* @param {ItemSet} parent | |||
* @param {Object} data Object containing parameters start, end | |||
* content, className. | |||
* @param {Object} [options] Options to set initial property values | |||
* // TODO: describe available options | |||
*/ | |||
function ItemRange (parent, data, options) { | |||
this.props = { | |||
content: { | |||
left: 0, | |||
width: 0 | |||
} | |||
}; | |||
Item.call(this, parent, data, options); | |||
} | |||
ItemRange.prototype = new Item (null, null); | |||
// register the ItemBox in the item types | |||
itemTypes['range'] = ItemRange; | |||
/** | |||
* Select the item | |||
* @override | |||
*/ | |||
ItemRange.prototype.select = function () { | |||
this.selected = true; | |||
// TODO: select and unselect | |||
}; | |||
/** | |||
* Unselect the item | |||
* @override | |||
*/ | |||
ItemRange.prototype.unselect = function () { | |||
this.selected = false; | |||
// TODO: select and unselect | |||
}; | |||
/** | |||
* Repaint the item | |||
* @return {Boolean} changed | |||
*/ | |||
ItemRange.prototype.repaint = function () { | |||
// TODO: make an efficient repaint | |||
var changed = false; | |||
var dom = this.dom; | |||
if (this.visible) { | |||
if (!dom) { | |||
this._create(); | |||
changed = true; | |||
} | |||
dom = this.dom; | |||
if (dom) { | |||
if (!this.options && !this.options.parent) { | |||
throw new Error('Cannot repaint item: no parent attached'); | |||
} | |||
var parentContainer = this.parent.getContainer(); | |||
if (!parentContainer) { | |||
throw new Error('Cannot repaint time axis: parent has no container element'); | |||
} | |||
if (!dom.box.parentNode) { | |||
parentContainer.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.data.id); | |||
} | |||
changed = true; | |||
} | |||
// update class | |||
var className = this.data.className ? ('' + this.data.className) : ''; | |||
if (this.className != className) { | |||
this.className = className; | |||
dom.box.className = 'item range' + className; | |||
changed = true; | |||
} | |||
} | |||
} | |||
else { | |||
// hide when visible | |||
if (dom) { | |||
if (dom.box.parentNode) { | |||
dom.box.parentNode.removeChild(dom.box); | |||
changed = true; | |||
} | |||
} | |||
} | |||
return changed; | |||
}; | |||
/** | |||
* Reflow the item: calculate its actual size from the DOM | |||
* @return {boolean} resized returns true if the axis is resized | |||
* @override | |||
*/ | |||
ItemRange.prototype.reflow = function () { | |||
if (this.data.start == undefined) { | |||
throw new Error('Property "start" missing in item ' + this.data.id); | |||
} | |||
if (this.data.end == undefined) { | |||
throw new Error('Property "end" missing in item ' + this.data.id); | |||
} | |||
var dom = this.dom, | |||
props = this.props, | |||
options = this.options, | |||
parent = this.parent, | |||
start = parent.toScreen(this.data.start), | |||
end = parent.toScreen(this.data.end), | |||
changed = 0; | |||
if (dom) { | |||
var update = util.updateProperty, | |||
box = dom.box, | |||
parentWidth = parent.width, | |||
orientation = options.orientation, | |||
contentLeft, | |||
top; | |||
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 * options.padding)); | |||
// TODO: remove the need for options.padding. it's terrible. | |||
} | |||
else { | |||
contentLeft = 0; | |||
} | |||
changed += update(props.content, 'left', contentLeft); | |||
if (orientation == 'top') { | |||
top = options.margin.axis; | |||
changed += update(this, 'top', top); | |||
} | |||
else { | |||
// default or 'bottom' | |||
top = parent.height - this.height - options.margin.axis; | |||
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; | |||
} | |||
return (changed > 0); | |||
}; | |||
/** | |||
* Create an items DOM | |||
* @private | |||
*/ | |||
ItemRange.prototype._create = function () { | |||
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); | |||
} | |||
}; | |||
/** | |||
* Reposition the item, recalculate its left, top, and width, using the current | |||
* range and size of the items itemset | |||
* @override | |||
*/ | |||
ItemRange.prototype.reposition = function () { | |||
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'; | |||
} | |||
}; |
@ -0,0 +1,416 @@ | |||
/** | |||
* 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 | |||
* 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. | |||
* @constructor ItemSet | |||
* @extends Panel | |||
*/ | |||
function ItemSet(parent, depends, options) { | |||
this.id = util.randomUUID(); | |||
this.parent = parent; | |||
this.depends = depends; | |||
// one options object is shared by this itemset and all its items | |||
this.options = { | |||
style: 'box', | |||
align: 'center', | |||
orientation: 'bottom', | |||
margin: { | |||
axis: 20, | |||
item: 10 | |||
}, | |||
padding: 5 | |||
}; | |||
var me = this; | |||
this.data = null; // DataSet | |||
this.range = null; // Range or Object {start: number, end: number} | |||
this.listeners = { | |||
'add': function (event, params) { | |||
me._onAdd(params.items); | |||
}, | |||
'update': function (event, params) { | |||
me._onUpdate(params.items); | |||
}, | |||
'remove': function (event, params) { | |||
me._onRemove(params.items); | |||
} | |||
}; | |||
this.items = {}; | |||
this.queue = {}; // queue with items to be added/updated/removed | |||
this.stack = new Stack(this); | |||
this.conversion = null; | |||
this.setOptions(options); | |||
} | |||
ItemSet.prototype = new Panel(); | |||
/** | |||
* Set options for the ItemSet. Existing options will be extended/overwritten. | |||
* @param {Object} [options] The following options are available: | |||
* {String | function} [className] | |||
* class name for the itemset | |||
* {String} [style] | |||
* Default style for the items. Choose from 'box' | |||
* (default), 'point', or 'range'. The default | |||
* Style can be overwritten by individual items. | |||
* {String} align | |||
* Alignment for the items, only applicable for | |||
* ItemBox. Choose 'center' (default), 'left', or | |||
* 'right'. | |||
* {String} orientation | |||
* Orientation of the item set. Choose 'top' or | |||
* 'bottom' (default). | |||
* {Number} margin.axis | |||
* Margin between the axis and the items in pixels. | |||
* Default is 20. | |||
* {Number} margin.item | |||
* Margin between items in pixels. Default is 10. | |||
* {Number} padding | |||
* Padding of the contents of an item in pixels. | |||
* Must correspond with the items css. Default is 5. | |||
*/ | |||
ItemSet.prototype.setOptions = function (options) { | |||
util.extend(this.options, options); | |||
// TODO: ItemSet should also attach event listeners for rangechange and rangechanged, like timeaxis | |||
this.stack.setOptions(this.options); | |||
}; | |||
/** | |||
* Set range (start and end). | |||
* @param {Range | Object} range A Range or an object containing start and end. | |||
*/ | |||
ItemSet.prototype.setRange = function (range) { | |||
if (!(range instanceof Range) && (!range || !range.start || !range.end)) { | |||
throw new TypeError('Range must be an instance of Range, ' + | |||
'or an object containing start and end.'); | |||
} | |||
this.range = range; | |||
}; | |||
/** | |||
* Repaint the component | |||
* @return {Boolean} changed | |||
*/ | |||
ItemSet.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 = 'itemset'; | |||
if (options.className) { | |||
util.addClassName(frame, util.option.asString(options.className)); | |||
} | |||
this.frame = frame; | |||
changed += 1; | |||
} | |||
if (!frame.parentNode) { | |||
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'); | |||
} | |||
parentContainer.appendChild(frame); | |||
changed += 1; | |||
} | |||
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%')); | |||
this._updateConversion(); | |||
var me = this, | |||
queue = this.queue, | |||
data = this.data, | |||
items = this.items, | |||
dataOptions = { | |||
fields: ['id', 'start', 'end', 'content', 'type'] | |||
}; | |||
// TODO: copy options from the itemset itself? | |||
// TODO: make orientation dynamically changable for the items | |||
// show/hide added/changed/removed items | |||
Object.keys(queue).forEach(function (id) { | |||
var entry = queue[id]; | |||
var item = entry.item; | |||
//noinspection FallthroughInSwitchStatementJS | |||
switch (entry.action) { | |||
case 'add': | |||
case 'update': | |||
var itemData = data.get(id, dataOptions); | |||
var type = itemData.type || | |||
(itemData.start && itemData.end && 'range') || | |||
'box'; | |||
var constructor = itemTypes[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, delete the item | |||
item.visible = false; | |||
changed += item.repaint(); | |||
item = null; | |||
} | |||
else { | |||
item.data = itemData; // TODO: create a method item.setData ? | |||
changed += item.repaint(); | |||
} | |||
} | |||
if (!item) { | |||
// create item | |||
if (constructor) { | |||
item = new constructor(me, itemData, options); | |||
changed += item.repaint(); | |||
} | |||
else { | |||
throw new TypeError('Unknown item type "' + type + '"'); | |||
} | |||
} | |||
// update lists | |||
items[id] = item; | |||
delete queue[id]; | |||
break; | |||
case 'remove': | |||
if (item) { | |||
// TODO: remove dom of the item | |||
item.visible = false; | |||
changed += item.repaint(); | |||
} | |||
// update lists | |||
delete items[id]; | |||
delete queue[id]; | |||
break; | |||
default: | |||
console.log('Error: unknown action "' + entry.action + '"'); | |||
} | |||
}); | |||
// reposition all items | |||
util.forEach(this.items, function (item) { | |||
item.reposition(); | |||
}); | |||
return (changed > 0); | |||
}; | |||
/** | |||
* Reflow the component | |||
* @return {Boolean} resized | |||
*/ | |||
ItemSet.prototype.reflow = function () { | |||
var changed = 0, | |||
options = this.options, | |||
update = util.updateProperty, | |||
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(); | |||
if (options.height != null) { | |||
changed += update(this, 'height', frame.offsetHeight); | |||
} | |||
else { | |||
// height is not specified, determine the height from the height and positioned items | |||
var frameHeight = this.height; | |||
var maxHeight = 0; | |||
if (options.orientation == 'top') { | |||
util.forEach(this.items, function (item) { | |||
maxHeight = Math.max(maxHeight, item.top + item.height); | |||
}); | |||
} | |||
else { | |||
// orientation == 'bottom' | |||
util.forEach(this.items, function (item) { | |||
maxHeight = Math.max(maxHeight, frameHeight - item.top); | |||
}); | |||
} | |||
changed += update(this, 'height', maxHeight + options.margin.axis); | |||
} | |||
// 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); | |||
}; | |||
/** | |||
* Set data | |||
* @param {DataSet | Array | DataTable} data | |||
*/ | |||
ItemSet.prototype.setData = function(data) { | |||
// unsubscribe from current dataset | |||
var current = this.data; | |||
if (current) { | |||
util.forEach(this.listeners, function (callback, event) { | |||
current.unsubscribe(event, callback); | |||
}); | |||
} | |||
if (data instanceof DataSet) { | |||
this.data = data; | |||
} | |||
else { | |||
this.data = new DataSet({ | |||
fieldTypes: { | |||
start: 'Date', | |||
end: 'Date' | |||
} | |||
}); | |||
this.data.add(data); | |||
} | |||
var id = this.id; | |||
var me = this; | |||
util.forEach(this.listeners, function (callback, event) { | |||
me.data.subscribe(event, callback, id); | |||
}); | |||
var dataItems = this.data.get({filter: ['id']}); | |||
var ids = []; | |||
util.forEach(dataItems, function (dataItem, index) { | |||
ids[index] = dataItem.id; | |||
}); | |||
this._onAdd(ids); | |||
}; | |||
/** | |||
* Handle updated items | |||
* @param {Number[]} ids | |||
* @private | |||
*/ | |||
ItemSet.prototype._onUpdate = function(ids) { | |||
this._toQueue(ids, 'update'); | |||
}; | |||
/** | |||
* Handle changed items | |||
* @param {Number[]} ids | |||
* @private | |||
*/ | |||
ItemSet.prototype._onAdd = function(ids) { | |||
this._toQueue(ids, 'add'); | |||
}; | |||
/** | |||
* Handle removed items | |||
* @param {Number[]} ids | |||
* @private | |||
*/ | |||
ItemSet.prototype._onRemove = function(ids) { | |||
this._toQueue(ids, 'remove'); | |||
}; | |||
/** | |||
* Put items in the queue to be added/updated/remove | |||
* @param {Number[]} ids | |||
* @param {String} action can be 'add', 'update', 'remove' | |||
*/ | |||
ItemSet.prototype._toQueue = function (ids, action) { | |||
var items = this.items; | |||
var queue = this.queue; | |||
ids.forEach(function (id) { | |||
var entry = queue[id]; | |||
if (entry) { | |||
// already queued, update the action of the entry | |||
entry.action = action; | |||
} | |||
else { | |||
// not yet queued, add an entry to the queue | |||
queue[id] = { | |||
item: items[id] || null, | |||
action: action | |||
}; | |||
} | |||
}); | |||
if (this.controller) { | |||
//this.requestReflow(); | |||
this.requestRepaint(); | |||
} | |||
}; | |||
/** | |||
* Calculate the factor 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 | |||
*/ | |||
ItemSet.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); | |||
} | |||
}; | |||
/** | |||
* 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(x) { | |||
var conversion = this.conversion; | |||
return new Date(x / conversion.factor + 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(time) { | |||
var conversion = this.conversion; | |||
return (time.valueOf() - conversion.offset) * conversion.factor; | |||
}; |
@ -0,0 +1,101 @@ | |||
/** | |||
* 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: | |||
* {String | Number | function} [left] | |||
* {String | Number | function} [top] | |||
* {String | Number | function} [width] | |||
* {String | Number | function} [height] | |||
* {String | function} [className] | |||
* @constructor Panel | |||
* @extends Component | |||
*/ | |||
function Panel(parent, depends, options) { | |||
this.id = util.randomUUID(); | |||
this.parent = parent; | |||
this.depends = depends; | |||
this.options = {}; | |||
this.setOptions(options); | |||
} | |||
Panel.prototype = new Component(); | |||
/** | |||
* Get the container element of the panel, which can be used by a child to | |||
* add its own widgets. | |||
* @returns {HTMLElement} container | |||
*/ | |||
Panel.prototype.getContainer = function () { | |||
return this.frame; | |||
}; | |||
/** | |||
* Repaint the component | |||
* @return {Boolean} changed | |||
*/ | |||
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 = 'panel'; | |||
if (options.className) { | |||
if (typeof options.className == 'function') { | |||
util.addClassName(frame, String(options.className())); | |||
} | |||
else { | |||
util.addClassName(frame, String(options.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 | |||
*/ | |||
Panel.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); | |||
}; |
@ -0,0 +1,200 @@ | |||
/** | |||
* A root panel can hold components. The root panel must be initialized with | |||
* a DOM element as container. | |||
* @param {HTMLElement} container | |||
* @param {Object} [options] Available parameters: see RootPanel.setOptions. | |||
* @constructor RootPanel | |||
* @extends Panel | |||
*/ | |||
function RootPanel(container, options) { | |||
this.id = util.randomUUID(); | |||
this.container = container; | |||
this.options = { | |||
autoResize: true | |||
}; | |||
this.listeners = {}; // event listeners | |||
this.setOptions(options); | |||
} | |||
RootPanel.prototype = new Panel(); | |||
/** | |||
* 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] | |||
* {String | Number | function} [height] | |||
* {Boolean | function} [autoResize] | |||
*/ | |||
RootPanel.prototype.setOptions = function (options) { | |||
util.extend(this.options, options); | |||
if (this.options.autoResize) { | |||
this._watch(); | |||
} | |||
else { | |||
this._unwatch(); | |||
} | |||
}; | |||
/** | |||
* 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; | |||
if (!frame) { | |||
frame = document.createElement('div'); | |||
frame.className = 'graph panel'; | |||
if (options.className) { | |||
util.addClassName(frame, util.option.asString(options.className)); | |||
} | |||
this.frame = frame; | |||
changed += 1; | |||
} | |||
if (!frame.parentNode) { | |||
if (!this.container) { | |||
throw new Error('Cannot repaint root panel: no container attached'); | |||
} | |||
this.container.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%')); | |||
this._updateEventEmitters(); | |||
return (changed > 0); | |||
}; | |||
/** | |||
* Reflow the component | |||
* @return {Boolean} resized | |||
*/ | |||
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; | |||
} | |||
return (changed > 0); | |||
}; | |||
/** | |||
* Watch for changes in the size of the frame. On resize, the Panel will | |||
* automatically redraw itself. | |||
* @private | |||
*/ | |||
RootPanel.prototype._watch = function () { | |||
var me = this; | |||
this._unwatch(); | |||
var checkSize = function () { | |||
if (!me.options.autoResize) { | |||
// stop watching when the option autoResize is changed to false | |||
me._unwatch(); | |||
return; | |||
} | |||
if (me.frame) { | |||
// check whether the frame is resized | |||
if ((me.frame.clientWidth != me.width) || | |||
(me.frame.clientHeight != me.height)) { | |||
me.requestReflow(); | |||
} | |||
} | |||
}; | |||
// TODO: automatically cleanup the event listener when the frame is deleted | |||
util.addEventListener(window, 'resize', checkSize); | |||
this.watchTimer = setInterval(checkSize, 1000); | |||
}; | |||
/** | |||
* Stop watching for a resize of the frame. | |||
* @private | |||
*/ | |||
RootPanel.prototype._unwatch = function () { | |||
if (this.watchTimer) { | |||
clearInterval(this.watchTimer); | |||
this.watchTimer = undefined; | |||
} | |||
// TODO: remove event listener on window.resize | |||
}; | |||
/** | |||
* Event handler | |||
* @param {String} event name of the event, for example 'click', 'mousemove' | |||
* @param {function} callback callback handler, invoked with the raw HTML Event | |||
* as parameter. | |||
*/ | |||
RootPanel.prototype.on = function (event, callback) { | |||
// register the listener at this component | |||
var arr = this.listeners[event]; | |||
if (!arr) { | |||
arr = []; | |||
this.listeners[event] = arr; | |||
} | |||
arr.push(callback); | |||
this._updateEventEmitters(); | |||
}; | |||
/** | |||
* Update the event listeners for all event emitters | |||
* @private | |||
*/ | |||
RootPanel.prototype._updateEventEmitters = function () { | |||
if (this.listeners) { | |||
var me = this; | |||
util.forEach(this.listeners, function (listeners, event) { | |||
if (!me.emitters) { | |||
me.emitters = {}; | |||
} | |||
if (!(event in me.emitters)) { | |||
// create event | |||
var frame = me.frame; | |||
if (frame) { | |||
//console.log('Created a listener for event ' + event + ' on component ' + me.id); // TODO: cleanup logging | |||
var callback = function(event) { | |||
listeners.forEach(function (listener) { | |||
// TODO: filter on event target! | |||
listener(event); | |||
}); | |||
}; | |||
me.emitters[event] = callback; | |||
util.addEventListener(frame, event, callback); | |||
} | |||
} | |||
}); | |||
// TODO: be able to delete event listeners | |||
// TODO: be able to move event listeners to a parent when available | |||
} | |||
}; |
@ -0,0 +1,521 @@ | |||
/** | |||
* 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 | |||
* options. | |||
* @constructor TimeAxis | |||
* @extends Component | |||
*/ | |||
function TimeAxis (parent, depends, options) { | |||
this.id = util.randomUUID(); | |||
this.parent = parent; | |||
this.depends = depends; | |||
this.dom = { | |||
majorLines: [], | |||
majorTexts: [], | |||
minorLines: [], | |||
minorTexts: [], | |||
redundant: { | |||
majorLines: [], | |||
majorTexts: [], | |||
minorLines: [], | |||
minorTexts: [] | |||
} | |||
}; | |||
this.props = { | |||
range: { | |||
start: 0, | |||
end: 0, | |||
minimumStep: 0 | |||
} | |||
}; | |||
this.options = { | |||
orientation: 'bottom', // supported: 'top', 'bottom' | |||
// TODO: implement timeaxis orientations 'left' and 'right' | |||
showMinorLabels: true, | |||
showMajorLabels: true | |||
}; | |||
this.conversion = null; | |||
this.range = null; | |||
this.setOptions(options); | |||
} | |||
TimeAxis.prototype = new Component(); | |||
// TODO: comment options | |||
TimeAxis.prototype.setOptions = function (options) { | |||
util.extend(this.options, options); | |||
}; | |||
/** | |||
* Set a range (start and end) | |||
* @param {Range | Object} range A Range or an object containing start and end. | |||
*/ | |||
TimeAxis.prototype.setRange = function (range) { | |||
if (!(range instanceof Range) && (!range || !range.start || !range.end)) { | |||
throw new TypeError('Range must be an instance of Range, ' + | |||
'or an object containing start and end.'); | |||
} | |||
this.range = 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.factor + 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 | |||
*/ | |||
TimeAxis.prototype.toScreen = function(time) { | |||
var conversion = this.conversion; | |||
return (time.valueOf() - conversion.offset) * conversion.factor; | |||
}; | |||
/** | |||
* Repaint the component | |||
* @return {Boolean} changed | |||
*/ | |||
TimeAxis.prototype.repaint = function () { | |||
var changed = 0, | |||
update = util.updateProperty, | |||
asSize = util.option.asSize, | |||
options = this.options, | |||
props = this.props, | |||
step = this.step; | |||
var frame = this.frame; | |||
if (!frame) { | |||
frame = document.createElement('div'); | |||
this.frame = frame; | |||
changed += 1; | |||
} | |||
frame.className = 'axis ' + options.orientation; | |||
// 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); | |||
changed += 1; | |||
} | |||
var parent = frame.parentNode; | |||
if (parent) { | |||
var beforeChild = frame.nextSibling; | |||
parent.removeChild(frame); // take frame offline while updating (is almost twice as fast) | |||
var orientation = options.orientation; | |||
var defaultTop = (orientation == 'bottom') ? (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 (options.showMinorLabels) { | |||
this._repaintMinorText(x, step.getLabelMinor()); | |||
} | |||
if (isMajor && options.showMajorLabels) { | |||
if (x > 0) { | |||
if (xFirstMajorLabel == undefined) { | |||
xFirstMajorLabel = x; | |||
} | |||
this._repaintMajorText(x, step.getLabelMajor()); | |||
} | |||
this._repaintMajorLine(x); | |||
} | |||
else { | |||
this._repaintMinorLine(x); | |||
} | |||
step.next(); | |||
} | |||
// create a major label on the left when needed | |||
if (options.showMajorLabels) { | |||
var leftTime = this.toTime(0), | |||
leftText = step.getLabelMajor(leftTime), | |||
widthText = leftText.length * (props.majorCharWidth || 10) + 10; // upper bound estimation | |||
if (xFirstMajorLabel == undefined || widthText < xFirstMajorLabel) { | |||
this._repaintMajorText(0, leftText); | |||
} | |||
} | |||
this._repaintEnd(); | |||
} | |||
this._repaintLine(); | |||
// put frame online again | |||
if (beforeChild) { | |||
parent.insertBefore(frame, beforeChild); | |||
} | |||
else { | |||
parent.appendChild(frame) | |||
} | |||
} | |||
return (changed > 0); | |||
}; | |||
/** | |||
* 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 | |||
* @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; | |||
dom.majorLines = []; | |||
dom.majorTexts = []; | |||
dom.minorLines = []; | |||
dom.minorTexts = []; | |||
}; | |||
/** | |||
* End a repaint. Cleanup leftover DOM elements in the redundant list | |||
* @private | |||
*/ | |||
TimeAxis.prototype._repaintEnd = function () { | |||
util.forEach(this.dom.redundant, function (arr) { | |||
while (arr.length) { | |||
var elem = arr.pop(); | |||
if (elem && elem.parentNode) { | |||
elem.parentNode.removeChild(elem); | |||
} | |||
} | |||
}); | |||
}; | |||
/** | |||
* Create a minor label for the axis at position x | |||
* @param {Number} x | |||
* @param {String} text | |||
* @private | |||
*/ | |||
TimeAxis.prototype._repaintMinorText = function (x, text) { | |||
// reuse redundant label | |||
var label = this.dom.redundant.minorTexts.shift(); | |||
if (!label) { | |||
// create new label | |||
var content = document.createTextNode(''); | |||
label = document.createElement('div'); | |||
label.appendChild(content); | |||
label.className = 'text minor'; | |||
this.frame.appendChild(label); | |||
} | |||
this.dom.minorTexts.push(label); | |||
label.childNodes[0].nodeValue = text; | |||
label.style.left = x + 'px'; | |||
label.style.top = this.props.minorLabelTop + 'px'; | |||
//label.title = title; // TODO: this is a heavy operation | |||
}; | |||
/** | |||
* Create a Major label for the axis at position x | |||
* @param {Number} x | |||
* @param {String} text | |||
* @private | |||
*/ | |||
TimeAxis.prototype._repaintMajorText = function (x, text) { | |||
// reuse redundant label | |||
var label = this.dom.redundant.majorTexts.shift(); | |||
if (!label) { | |||
// create label | |||
var content = document.createTextNode(text); | |||
label = document.createElement('div'); | |||
label.className = 'text major'; | |||
label.appendChild(content); | |||
this.frame.appendChild(label); | |||
} | |||
this.dom.majorTexts.push(label); | |||
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 | |||
}; | |||
/** | |||
* Create a minor line for the axis at position x | |||
* @param {Number} x | |||
* @private | |||
*/ | |||
TimeAxis.prototype._repaintMinorLine = function (x) { | |||
// reuse redundant line | |||
var line = this.dom.redundant.minorLines.shift(); | |||
if (!line) { | |||
// create vertical line | |||
line = document.createElement('div'); | |||
line.className = 'grid vertical minor'; | |||
this.frame.appendChild(line); | |||
} | |||
this.dom.minorLines.push(line); | |||
var props = this.props; | |||
line.style.top = props.minorLineTop + 'px'; | |||
line.style.height = props.minorLineHeight + 'px'; | |||
line.style.left = (x - props.minorLineWidth / 2) + 'px'; | |||
}; | |||
/** | |||
* Create a Major line for the axis at position x | |||
* @param {Number} x | |||
* @private | |||
*/ | |||
TimeAxis.prototype._repaintMajorLine = function (x) { | |||
// reuse redundant line | |||
var line = this.dom.redundant.majorLines.shift(); | |||
if (!line) { | |||
// create vertical line | |||
line = document.createElement('DIV'); | |||
line.className = 'grid vertical major'; | |||
this.frame.appendChild(line); | |||
} | |||
this.dom.majorLines.push(line); | |||
var props = this.props; | |||
line.style.top = props.majorLineTop + 'px'; | |||
line.style.left = (x - props.majorLineWidth / 2) + 'px'; | |||
line.style.height = props.majorLineHeight + 'px'; | |||
}; | |||
/** | |||
* Repaint the horizontal line for the axis | |||
* @private | |||
*/ | |||
TimeAxis.prototype._repaintLine = function() { | |||
var line = this.dom.line, | |||
frame = this.frame, | |||
options = this.options; | |||
// line before all axis elements | |||
if (options.showMinorLabels || options.showMajorLabels) { | |||
if (line) { | |||
// put this line at the end of all childs | |||
frame.removeChild(line); | |||
frame.appendChild(line); | |||
} | |||
else { | |||
// create the axis line | |||
line = document.createElement('div'); | |||
line.className = 'grid horizontal major'; | |||
frame.appendChild(line); | |||
this.dom.line = line; | |||
} | |||
line.style.top = this.props.lineTop + 'px'; | |||
} | |||
else { | |||
if (line && axis.parentElement) { | |||
frame.removeChild(axis.line); | |||
delete this.dom.line; | |||
} | |||
} | |||
}; | |||
/** | |||
* Create characters used to determine the size of text on the axis | |||
* @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.characterMinor) { | |||
text = document.createTextNode('0'); | |||
var measureCharMinor = document.createElement('DIV'); | |||
measureCharMinor.className = 'text minor measure'; | |||
measureCharMinor.appendChild(text); | |||
this.frame.appendChild(measureCharMinor); | |||
dom.measureCharMinor = measureCharMinor; | |||
} | |||
if (!dom.characterMajor) { | |||
text = document.createTextNode('0'); | |||
var measureCharMajor = document.createElement('DIV'); | |||
measureCharMajor.className = 'text major measure'; | |||
measureCharMajor.appendChild(text); | |||
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); | |||
// calculate size of a character | |||
var props = this.props, | |||
showMinorLabels = this.options.showMinorLabels, | |||
showMajorLabels = this.options.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.options.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 = this.top + props.majorLabelHeight; | |||
props.minorLineWidth = 1; // TODO: really calculate width | |||
props.majorLineTop = -this.top; | |||
props.majorLineHeight = this.top + props.minorLabelHeight + props.majorLabelHeight; | |||
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 = parentHeight - props.majorLabelHeight - this.top; | |||
props.minorLineWidth = 1; // TODO: really calculate width | |||
props.majorLineTop = 0; | |||
props.majorLineHeight = parentHeight - this.top; | |||
props.majorLineWidth = 1; // TODO: really calculate width | |||
props.lineTop = props.majorLabelHeight + props.minorLabelHeight; | |||
break; | |||
default: | |||
throw new Error('Unkown orientation "' + this.options.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.cast(range.start, 'Date'), | |||
end = util.cast(range.end, 'Date'), | |||
minimumStep = this.toTime((props.minorCharWidth || 10) * 5) - this.toTime(0); | |||
this.step = new TimeStep(start, end, minimumStep); | |||
changed += update(props.range, 'start', start.valueOf()); | |||
changed += update(props.range, 'end', end.valueOf()); | |||
changed += update(props.range, 'minimumStep', minimumStep.valueOf()); | |||
} | |||
return (changed > 0); | |||
}; | |||
/** | |||
* Calculate the factor 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); | |||
} | |||
}; |
@ -0,0 +1,139 @@ | |||
/** | |||
* @constructor Controller | |||
* | |||
* A Controller controls the reflows and repaints of all visual components | |||
*/ | |||
function Controller () { | |||
this.id = util.randomUUID(); | |||
this.components = {}; | |||
this.repaintTimer = undefined; | |||
this.reflowTimer = undefined; | |||
} | |||
/** | |||
* Add a component to the controller | |||
* @param {Component | Controller} component | |||
*/ | |||
Controller.prototype.add = function (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.controller = this; | |||
this.components[component.id] = component; | |||
}; | |||
/** | |||
* Request a reflow. The controller will schedule a reflow | |||
*/ | |||
Controller.prototype.requestReflow = function () { | |||
if (!this.reflowTimer) { | |||
var me = this; | |||
this.reflowTimer = setTimeout(function () { | |||
me.reflowTimer = undefined; | |||
me.reflow(); | |||
}, 0); | |||
} | |||
}; | |||
/** | |||
* Request a repaint. The controller will schedule a repaint | |||
*/ | |||
Controller.prototype.requestRepaint = function () { | |||
if (!this.repaintTimer) { | |||
var me = this; | |||
this.repaintTimer = setTimeout(function () { | |||
me.repaintTimer = undefined; | |||
me.repaint(); | |||
}, 0); | |||
} | |||
}; | |||
/** | |||
* Repaint all components | |||
*/ | |||
Controller.prototype.repaint = function () { | |||
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); | |||
// 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 () { | |||
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); | |||
// immediately repaint when needed | |||
if (resized) { | |||
this.repaint(); | |||
} | |||
// TODO: limit the number of nested reflows/repaints, prevent loop | |||
}; |
@ -0,0 +1,502 @@ | |||
/** | |||
* DataSet | |||
* | |||
* Usage: | |||
* var dataSet = new DataSet({ | |||
* fieldId: '_id', | |||
* fieldTypes: { | |||
* // ... | |||
* } | |||
* }); | |||
* | |||
* dataSet.add(item); | |||
* dataSet.add(data); | |||
* dataSet.update(item); | |||
* dataSet.update(data); | |||
* dataSet.remove(id); | |||
* dataSet.remove(ids); | |||
* var data = dataSet.get(); | |||
* var data = dataSet.get(id); | |||
* var data = dataSet.get(ids); | |||
* var data = dataSet.get(ids, options, data); | |||
* dataSet.clear(); | |||
* | |||
* A data set can: | |||
* - add/remove/update data | |||
* - gives triggers upon changes in the data | |||
* - can import/export data in various data formats | |||
* @param {Object} [options] Available options: | |||
* {String} fieldId Field name of the id in the | |||
* items, 'id' by default. | |||
* {Object.<String, String} fieldTypes | |||
* A map with field names as key, | |||
* and the field type as value. | |||
*/ | |||
function DataSet (options) { | |||
var me = this; | |||
this.options = options || {}; | |||
this.data = {}; // map with data indexed by id | |||
this.fieldId = this.options.fieldId || 'id'; // name of the field containing id | |||
this.fieldTypes = {}; // field types by field name | |||
if (this.options.fieldTypes) { | |||
util.forEach(this.options.fieldTypes, function (value, field) { | |||
if (value == 'Date' || value == 'ISODate' || value == 'ASPDate') { | |||
me.fieldTypes[field] = 'Date'; | |||
} | |||
else { | |||
me.fieldTypes[field] = value; | |||
} | |||
}); | |||
} | |||
// event subscribers | |||
this.subscribers = {}; | |||
this.internalIds = {}; // internally generated id's | |||
} | |||
/** | |||
* Subscribe to an event, add an event listener | |||
* @param {String} event Event name. Available events: 'put', 'update', | |||
* 'remove' | |||
* @param {function} callback Callback method. Called with three parameters: | |||
* {String} event | |||
* {Object | null} params | |||
* {String} senderId | |||
* @param {String} [id] Optional id for the sender, used to filter | |||
* events triggered by the sender itself. | |||
*/ | |||
DataSet.prototype.subscribe = function (event, callback, id) { | |||
var subscribers = this.subscribers[event]; | |||
if (!subscribers) { | |||
subscribers = []; | |||
this.subscribers[event] = subscribers; | |||
} | |||
subscribers.push({ | |||
id: id ? String(id) : null, | |||
callback: callback | |||
}); | |||
}; | |||
/** | |||
* Unsubscribe from an event, remove an event listener | |||
* @param {String} event | |||
* @param {function} callback | |||
*/ | |||
DataSet.prototype.unsubscribe = function (event, callback) { | |||
var subscribers = this.subscribers[event]; | |||
if (subscribers) { | |||
this.subscribers[event] = subscribers.filter(function (listener) { | |||
return (listener.callback != callback); | |||
}); | |||
} | |||
}; | |||
/** | |||
* Trigger an event | |||
* @param {String} event | |||
* @param {Object | null} params | |||
* @param {String} [senderId] Optional id of the sender. The event will | |||
* be triggered for all subscribers except the | |||
* sender itself. | |||
* @private | |||
*/ | |||
DataSet.prototype._trigger = function (event, params, senderId) { | |||
if (event == '*') { | |||
throw new Error('Cannot trigger event *'); | |||
} | |||
var subscribers = []; | |||
if (event in this.subscribers) { | |||
subscribers = subscribers.concat(this.subscribers[event]); | |||
} | |||
if ('*' in this.subscribers) { | |||
subscribers = subscribers.concat(this.subscribers['*']); | |||
} | |||
subscribers.forEach(function (listener) { | |||
if (listener.id != senderId && listener.callback) { | |||
listener.callback(event, params, senderId || null); | |||
} | |||
}); | |||
}; | |||
/** | |||
* Add data. Existing items with the same id will be overwritten. | |||
* @param {Object | Array | DataTable} data | |||
* @param {String} [senderId] Optional sender id, used to trigger events for | |||
* all but this sender's event subscribers. | |||
*/ | |||
DataSet.prototype.add = function (data, senderId) { | |||
var items = [], | |||
id, | |||
me = this; | |||
if (data instanceof Array) { | |||
// Array | |||
data.forEach(function (item) { | |||
var id = me._addItem(item); | |||
items.push(id); | |||
}); | |||
} | |||
else if (util.isDataTable(data)) { | |||
// Google DataTable | |||
var columns = this._getColumnNames(data); | |||
for (var row = 0, rows = data.getNumberOfRows(); row < rows; row++) { | |||
var item = {}; | |||
columns.forEach(function (field, col) { | |||
item[field] = data.getValue(row, col); | |||
}); | |||
id = me._addItem(item); | |||
items.push(id); | |||
} | |||
} | |||
else if (data instanceof Object) { | |||
// Single item | |||
id = me._addItem(data); | |||
items.push(id); | |||
} | |||
else { | |||
throw new Error('Unknown dataType'); | |||
} | |||
this._trigger('add', {items: items}, senderId); | |||
}; | |||
/** | |||
* Update existing items. Items with the same id will be merged | |||
* @param {Object | Array | DataTable} data | |||
* @param {String} [senderId] Optional sender id, used to trigger events for | |||
* all but this sender's event subscribers. | |||
*/ | |||
DataSet.prototype.update = function (data, senderId) { | |||
var items = [], | |||
id, | |||
me = this; | |||
if (data instanceof Array) { | |||
// Array | |||
data.forEach(function (item) { | |||
var id = me._updateItem(item); | |||
items.push(id); | |||
}); | |||
} | |||
else if (util.isDataTable(data)) { | |||
// Google DataTable | |||
var columns = this._getColumnNames(data); | |||
for (var row = 0, rows = data.getNumberOfRows(); row < rows; row++) { | |||
var item = {}; | |||
columns.forEach(function (field, col) { | |||
item[field] = data.getValue(row, col); | |||
}); | |||
id = me._updateItem(item); | |||
items.push(id); | |||
} | |||
} | |||
else if (data instanceof Object) { | |||
// Single item | |||
id = me._updateItem(data); | |||
items.push(id); | |||
} | |||
else { | |||
throw new Error('Unknown dataType'); | |||
} | |||
this._trigger('update', {items: items}, senderId); | |||
}; | |||
/** | |||
* Get a data item or multiple items | |||
* @param {String | Number | Array | Object} [ids] Id of a single item, or an | |||
* array with multiple id's, or | |||
* undefined or an Object with options | |||
* to retrieve all data. | |||
* @param {Object} [options] Available options: | |||
* {String} [type] | |||
* 'DataTable' or 'Array' (default) | |||
* {Object.<String, String>} [fieldTypes] | |||
* {String[]} [fields] filter fields | |||
* @param {Array | DataTable} [data] If provided, items will be appended | |||
* to this array or table. Required | |||
* in case of Google DataTable | |||
* @return {Array | Object | DataTable | null} data | |||
* @throws Error | |||
*/ | |||
DataSet.prototype.get = function (ids, options, data) { | |||
var me = this; | |||
// shift arguments when first argument contains the options | |||
if (util.getType(ids) == 'Object') { | |||
data = options; | |||
options = ids; | |||
ids = undefined; | |||
} | |||
// merge field types | |||
var fieldTypes = {}; | |||
if (this.options && this.options.fieldTypes) { | |||
util.forEach(this.options.fieldTypes, function (value, field) { | |||
fieldTypes[field] = value; | |||
}); | |||
} | |||
if (options && options.fieldTypes) { | |||
util.forEach(options.fieldTypes, function (value, field) { | |||
fieldTypes[field] = value; | |||
}); | |||
} | |||
var fields = options ? options.fields : undefined; | |||
// determine the return type | |||
var type; | |||
if (options && options.type) { | |||
type = (options.type == 'DataTable') ? 'DataTable' : 'Array'; | |||
if (data && (type != util.getType(data))) { | |||
throw new Error('Type of parameter "data" (' + util.getType(data) + ') ' + | |||
'does not correspond with specified options.type (' + options.type + ')'); | |||
} | |||
if (type == 'DataTable' && !util.isDataTable(data)) { | |||
throw new Error('Parameter "data" must be a DataTable ' + | |||
'when options.type is "DataTable"'); | |||
} | |||
} | |||
else if (data) { | |||
type = (util.getType(data) == 'DataTable') ? 'DataTable' : 'Array'; | |||
} | |||
else { | |||
type = 'Array'; | |||
} | |||
if (type == 'DataTable') { | |||
// return a Google DataTable | |||
var columns = this._getColumnNames(data); | |||
if (ids == undefined) { | |||
// return all data | |||
util.forEach(this.data, function (item) { | |||
me._appendRow(data, columns, me._castItem(item)); | |||
}); | |||
} | |||
else if (util.isNumber(ids) || util.isString(ids)) { | |||
var item = me._castItem(me.data[ids], fieldTypes, fields); | |||
this._appendRow(data, columns, item); | |||
} | |||
else if (ids instanceof Array) { | |||
ids.forEach(function (id) { | |||
var item = me._castItem(me.data[id], fieldTypes, fields); | |||
me._appendRow(data, columns, item); | |||
}); | |||
} | |||
else { | |||
throw new TypeError('Parameter "ids" must be ' + | |||
'undefined, a String, Number, or Array'); | |||
} | |||
} | |||
else { | |||
// return an array | |||
data = data || []; | |||
if (ids == undefined) { | |||
// return all data | |||
util.forEach(this.data, function (item) { | |||
data.push(me._castItem(item, fieldTypes, fields)); | |||
}); | |||
} | |||
else if (util.isNumber(ids) || util.isString(ids)) { | |||
// return a single item | |||
return this._castItem(me.data[ids], fieldTypes, fields); | |||
} | |||
else if (ids instanceof Array) { | |||
ids.forEach(function (id) { | |||
data.push(me._castItem(me.data[id], fieldTypes, fields)); | |||
}); | |||
} | |||
else { | |||
throw new TypeError('Parameter "ids" must be ' + | |||
'undefined, a String, Number, or Array'); | |||
} | |||
} | |||
return data; | |||
}; | |||
/** | |||
* Remove an object by pointer or by id | |||
* @param {String | Number | Object | Array} id Object or id, or an array with | |||
* objects or ids to be removed | |||
* @param {String} [senderId] Optional sender id, used to trigger events for | |||
* all but this sender's event subscribers. | |||
*/ | |||
DataSet.prototype.remove = function (id, senderId) { | |||
var items = [], | |||
me = this; | |||
if (util.isNumber(id) || util.isString(id)) { | |||
delete this.data[id]; | |||
delete this.internalIds[id]; | |||
items.push(id); | |||
} | |||
else if (id instanceof Array) { | |||
id.forEach(function (id) { | |||
me.remove(id); | |||
}); | |||
items = items.concat(id); | |||
} | |||
else if (id instanceof Object) { | |||
// search for the object | |||
for (var i in this.data) { | |||
if (this.data.hasOwnProperty(i)) { | |||
if (this.data[i] == id) { | |||
delete this.data[i]; | |||
delete this.internalIds[i]; | |||
items.push(i); | |||
} | |||
} | |||
} | |||
} | |||
this._trigger('remove', {items: items}, senderId); | |||
}; | |||
/** | |||
* Clear the data | |||
* @param {String} [senderId] Optional sender id, used to trigger events for | |||
* all but this sender's event subscribers. | |||
*/ | |||
DataSet.prototype.clear = function (senderId) { | |||
var items = Object.keys(this.data); | |||
this.data = []; | |||
this.internalIds = {}; | |||
this._trigger('remove', {items: items}, senderId); | |||
}; | |||
/** | |||
* Add a single item | |||
* @param {Object} item | |||
* @return {String} id | |||
* @private | |||
*/ | |||
DataSet.prototype._addItem = function (item) { | |||
var id = item[this.fieldId]; | |||
if (id == undefined) { | |||
// generate an id | |||
id = util.randomUUID(); | |||
item[this.fieldId] = id; | |||
this.internalIds[id] = item; | |||
} | |||
var d = {}; | |||
for (var field in item) { | |||
if (item.hasOwnProperty(field)) { | |||
var type = this.fieldTypes[field]; // type may be undefined | |||
d[field] = util.cast(item[field], type); | |||
} | |||
} | |||
this.data[id] = d; | |||
//TODO: fail when an item with this id already exists? | |||
return id; | |||
}; | |||
/** | |||
* Cast and filter the fields of an item | |||
* @param {Object | undefined} item | |||
* @param {Object.<String, String>} [fieldTypes] | |||
* @param {String[]} [fields] | |||
* @return {Object | null} castedItem | |||
* @private | |||
*/ | |||
DataSet.prototype._castItem = function (item, fieldTypes, fields) { | |||
var clone, | |||
fieldId = this.fieldId, | |||
internalIds = this.internalIds; | |||
if (item) { | |||
clone = {}; | |||
fieldTypes = fieldTypes || {}; | |||
if (fields) { | |||
// output filtered fields | |||
util.forEach(item, function (value, field) { | |||
if (fields.indexOf(field) != -1) { | |||
clone[field] = util.cast(value, fieldTypes[field]); | |||
} | |||
}); | |||
} | |||
else { | |||
// output all fields, except internal ids | |||
util.forEach(item, function (value, field) { | |||
if (field != fieldId || !(value in internalIds)) { | |||
clone[field] = util.cast(value, fieldTypes[field]); | |||
} | |||
}); | |||
} | |||
} | |||
else { | |||
clone = null; | |||
} | |||
return clone; | |||
}; | |||
/** | |||
* Update a single item: merge with existing item | |||
* @param {Object} item | |||
* @return {String} id | |||
* @private | |||
*/ | |||
DataSet.prototype._updateItem = function (item) { | |||
var id = item[this.fieldId]; | |||
if (id == undefined) { | |||
throw new Error('Item has no id (item: ' + JSON.stringify(item) + ')'); | |||
} | |||
var d = this.data[id]; | |||
if (d) { | |||
// merge with current item | |||
for (var field in item) { | |||
if (item.hasOwnProperty(field)) { | |||
var type = this.fieldTypes[field]; // type may be undefined | |||
d[field] = util.cast(item[field], type); | |||
} | |||
} | |||
} | |||
else { | |||
// create new item | |||
this._addItem(item); | |||
} | |||
return id; | |||
}; | |||
/** | |||
* Get an array with the column names of a Google DataTable | |||
* @param {DataTable} dataTable | |||
* @return {Array} columnNames | |||
* @private | |||
*/ | |||
DataSet.prototype._getColumnNames = function (dataTable) { | |||
var columns = []; | |||
for (var col = 0, cols = dataTable.getNumberOfColumns(); col < cols; col++) { | |||
columns[col] = dataTable.getColumnId(col) || dataTable.getColumnLabel(col); | |||
} | |||
return columns; | |||
}; | |||
/** | |||
* Append an item as a row to the dataTable | |||
* @param dataTable | |||
* @param columns | |||
* @param item | |||
* @private | |||
*/ | |||
DataSet.prototype._appendRow = function (dataTable, columns, item) { | |||
var row = dataTable.addRow(); | |||
columns.forEach(function (field, col) { | |||
dataTable.setValue(row, col, item[field]); | |||
}); | |||
}; |
@ -0,0 +1,116 @@ | |||
/** | |||
* Event listener (singleton) | |||
*/ | |||
var events = { | |||
'listeners': [], | |||
/** | |||
* Find a single listener by its object | |||
* @param {Object} object | |||
* @return {Number} index -1 when not found | |||
*/ | |||
'indexOf': function (object) { | |||
var listeners = this.listeners; | |||
for (var i = 0, iMax = this.listeners.length; i < iMax; i++) { | |||
var listener = listeners[i]; | |||
if (listener && listener.object == object) { | |||
return i; | |||
} | |||
} | |||
return -1; | |||
}, | |||
/** | |||
* Add an event listener | |||
* @param {Object} object | |||
* @param {String} event The name of an event, for example 'select' | |||
* @param {function} callback The callback method, called when the | |||
* event takes place | |||
*/ | |||
'addListener': function (object, event, callback) { | |||
var index = this.indexOf(object); | |||
var listener = this.listeners[index]; | |||
if (!listener) { | |||
listener = { | |||
'object': object, | |||
'events': {} | |||
}; | |||
this.listeners.push(listener); | |||
} | |||
var callbacks = listener.events[event]; | |||
if (!callbacks) { | |||
callbacks = []; | |||
listener.events[event] = callbacks; | |||
} | |||
// add the callback if it does not yet exist | |||
if (callbacks.indexOf(callback) == -1) { | |||
callbacks.push(callback); | |||
} | |||
}, | |||
/** | |||
* Remove an event listener | |||
* @param {Object} object | |||
* @param {String} event The name of an event, for example 'select' | |||
* @param {function} callback The registered callback method | |||
*/ | |||
'removeListener': function (object, event, callback) { | |||
var index = this.indexOf(object); | |||
var listener = this.listeners[index]; | |||
if (listener) { | |||
var callbacks = listener.events[event]; | |||
if (callbacks) { | |||
index = callbacks.indexOf(callback); | |||
if (index != -1) { | |||
callbacks.splice(index, 1); | |||
} | |||
// remove the array when empty | |||
if (callbacks.length == 0) { | |||
delete listener.events[event]; | |||
} | |||
} | |||
// count the number of registered events. remove listener when empty | |||
var count = 0; | |||
var events = listener.events; | |||
for (var e in events) { | |||
if (events.hasOwnProperty(e)) { | |||
count++; | |||
} | |||
} | |||
if (count == 0) { | |||
delete this.listeners[index]; | |||
} | |||
} | |||
}, | |||
/** | |||
* Remove all registered event listeners | |||
*/ | |||
'removeAllListeners': function () { | |||
this.listeners = []; | |||
}, | |||
/** | |||
* Trigger an event. All registered event handlers will be called | |||
* @param {Object} object | |||
* @param {String} event | |||
* @param {Object} properties (optional) | |||
*/ | |||
'trigger': function (object, event, properties) { | |||
var index = this.indexOf(object); | |||
var listener = this.listeners[index]; | |||
if (listener) { | |||
var callbacks = listener.events[event]; | |||
if (callbacks) { | |||
for (var i = 0, iMax = callbacks.length; i < iMax; i++) { | |||
callbacks[i](properties); | |||
} | |||
} | |||
} | |||
} | |||
}; |
@ -0,0 +1,62 @@ | |||
<!DOCTYPE HTML> | |||
<html> | |||
<head> | |||
<title>Timeline demo</title> | |||
<script src="../timeline.js"></script> | |||
<link href="../timeline.css" rel="stylesheet" type="text/css" /> | |||
<style> | |||
body, html { | |||
width: 100%; | |||
height: 100%; | |||
padding: 0; | |||
margin: 0; | |||
font-family: arial, sans-serif; | |||
font-size: 12pt; | |||
} | |||
#visualization { | |||
box-sizing: border-box; | |||
padding: 10px; | |||
width: 100%; | |||
height: 300px; | |||
} | |||
#visualization .itemset { | |||
/*background: rgba(255, 255, 0, 0.5);*/ | |||
} | |||
</style> | |||
</head> | |||
<body> | |||
<div id="visualization"></div> | |||
<script> | |||
// create a dataset with items | |||
var now = moment().minutes(0).seconds(0).milliseconds(0); | |||
var data = new DataSet({ | |||
fieldTypes: { | |||
start: 'Date', | |||
end: 'Date' | |||
} | |||
}); | |||
data.add([ | |||
{id: 1, content: 'item 1<br>start', start: now.clone().add('days', 4).toDate()}, | |||
{id: 2, content: 'item 2', start: now.clone().add('days', -2).toDate() }, | |||
{id: 3, content: 'item 3', start: now.clone().add('days', 2).toDate()}, | |||
{id: 4, content: 'item 4', start: now.clone().add('days', 0).toDate(), end: now.clone().add('days', 3).toDate()}, | |||
{id: 5, content: 'item 5', start: now.clone().add('days', 9).toDate(), type:'point'}, | |||
{id: 6, content: 'item 6', start: now.clone().add('days', 11).toDate()} | |||
]); | |||
var container = document.getElementById('visualization'); | |||
var options = { | |||
start: now.clone().add('days', -3).valueOf(), | |||
end: now.clone().add('days', 7).valueOf() | |||
}; | |||
var timeline = new Timeline(container, data, options); | |||
</script> | |||
</body> | |||
</html> |
@ -0,0 +1,24 @@ | |||
/** | |||
* @@name | |||
* https://github.com/almende/vis | |||
* | |||
* A dynamic, browser-based visualization library. | |||
* | |||
* @version @@version | |||
* @date @@date | |||
* | |||
* @license | |||
* Copyright (C) 2011-2013 Almende B.V, http://almende.com | |||
* | |||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not | |||
* use this file except in compliance with the License. You may obtain a copy | |||
* of the License at | |||
* | |||
* http://www.apache.org/licenses/LICENSE-2.0 | |||
* | |||
* Unless required by applicable law or agreed to in writing, software | |||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |||
* License for the specific language governing permissions and limitations under | |||
* the License. | |||
*/ |
@ -0,0 +1,524 @@ | |||
/** | |||
* @constructor Range | |||
* A Range controls a numeric range with a start and end value. | |||
* The Range adjusts the range based on mouse events or programmatic changes, | |||
* and triggers events when the range is changing or has been changed. | |||
* @param {Object} [options] See description at Range.setOptions | |||
* @extends Controller | |||
*/ | |||
function Range(options) { | |||
this.id = util.randomUUID(); | |||
this.start = 0; // Number | |||
this.end = 0; // Number | |||
this.options = { | |||
min: null, | |||
max: null, | |||
zoomMin: null, | |||
zoomMax: null | |||
}; | |||
this.setOptions(options); | |||
this.listeners = []; | |||
} | |||
/** | |||
* Set options for the range controller | |||
* @param {Object} options Available options: | |||
* {Number} start Set start value of the range | |||
* {Number} end Set end value of the range | |||
* {Number} min Minimum value for start | |||
* {Number} max Maximum value for end | |||
* {Number} zoomMin Set a minimum value for | |||
* (end - start). | |||
* {Number} zoomMax Set a maximum value for | |||
* (end - start). | |||
*/ | |||
Range.prototype.setOptions = function (options) { | |||
util.extend(this.options, options); | |||
if (options.start != null || options.end != null) { | |||
this.setRange(options.start, options.end); | |||
} | |||
}; | |||
/** | |||
* Add listeners for mouse and touch events to the component | |||
* @param {Component} component | |||
* @param {String} event Available events: 'move', 'zoom' | |||
* @param {String} direction Available directions: 'horizontal', 'vertical' | |||
*/ | |||
Range.prototype.subscribe = function (component, event, direction) { | |||
var me = this; | |||
var listener; | |||
if (direction != 'horizontal' && direction != 'vertical') { | |||
throw new TypeError('Unknown direction "' + direction + '". ' + | |||
'Choose "horizontal" or "vertical".'); | |||
} | |||
//noinspection FallthroughInSwitchStatementJS | |||
if (event == 'move') { | |||
listener = { | |||
component: component, | |||
event: event, | |||
direction: direction, | |||
callback: function (event) { | |||
me._onMouseDown(event, listener); | |||
}, | |||
params: {} | |||
}; | |||
component.on('mousedown', listener.callback); | |||
me.listeners.push(listener); | |||
} | |||
else if (event == 'zoom') { | |||
listener = { | |||
component: component, | |||
event: event, | |||
direction: direction, | |||
callback: function (event) { | |||
me._onMouseWheel(event, listener); | |||
}, | |||
params: {} | |||
}; | |||
component.on('mousewheel', listener.callback); | |||
me.listeners.push(listener); | |||
} | |||
else { | |||
throw new TypeError('Unknown event "' + event + '". ' + | |||
'Choose "move" or "zoom".'); | |||
} | |||
}; | |||
/** | |||
* Event handler | |||
* @param {String} event name of the event, for example 'click', 'mousemove' | |||
* @param {function} callback callback handler, invoked with the raw HTML Event | |||
* as parameter. | |||
*/ | |||
Range.prototype.on = function (event, callback) { | |||
events.addListener(this, event, callback); | |||
}; | |||
/** | |||
* Trigger an event | |||
* @param {String} event name of the event, available events: 'rangechange', | |||
* 'rangechanged' | |||
* @private | |||
*/ | |||
Range.prototype._trigger = function (event) { | |||
events.trigger(this, event, { | |||
start: this.start, | |||
end: this.end | |||
}); | |||
}; | |||
/** | |||
* Set a new start and end range | |||
* @param {Number} start | |||
* @param {Number} end | |||
*/ | |||
Range.prototype.setRange = function(start, end) { | |||
var changed = this._applyRange(start, end); | |||
if (changed) { | |||
this._trigger('rangechange'); | |||
this._trigger('rangechanged'); | |||
} | |||
}; | |||
/** | |||
* Set a new start and end range. This method is the same as setRange, but | |||
* does not trigger a range change and range changed event, and it returns | |||
* true when the range is changed | |||
* @param {Number} start | |||
* @param {Number} end | |||
* @return {Boolean} changed | |||
* @private | |||
*/ | |||
Range.prototype._applyRange = function(start, end) { | |||
var newStart = util.cast(start, 'Number'); | |||
var newEnd = util.cast(end, 'Number'); | |||
var diff; | |||
// check for valid number | |||
if (newStart == null || isNaN(newStart)) { | |||
throw new Error('Invalid start "' + start + '"'); | |||
} | |||
if (newEnd == null || isNaN(newEnd)) { | |||
throw new Error('Invalid end "' + end + '"'); | |||
} | |||
// prevent start < end | |||
if (newEnd < newStart) { | |||
newEnd = newStart; | |||
} | |||
// prevent start < min | |||
if (this.options.min != null) { | |||
var min = this.options.min.valueOf(); | |||
if (newStart < min) { | |||
diff = (min - newStart); | |||
newStart += diff; | |||
newEnd += diff; | |||
} | |||
} | |||
// prevent end > max | |||
if (this.options.max != null) { | |||
var max = this.options.max.valueOf(); | |||
if (newEnd > max) { | |||
diff = (newEnd - max); | |||
newStart -= diff; | |||
newEnd -= diff; | |||
} | |||
} | |||
// prevent (end-start) > zoomMin | |||
if (this.options.zoomMin != null) { | |||
var zoomMin = this.options.zoomMin.valueOf(); | |||
if (zoomMin < 0) { | |||
zoomMin = 0; | |||
} | |||
if ((newEnd - newStart) < zoomMin) { | |||
if ((this.end - this.start) > zoomMin) { | |||
// zoom to the minimum | |||
diff = (zoomMin - (newEnd - newStart)); | |||
newStart -= diff / 2; | |||
newEnd += diff / 2; | |||
} | |||
else { | |||
// ingore this action, we are already zoomed to the minimum | |||
newStart = this.start; | |||
newEnd = this.end; | |||
} | |||
} | |||
} | |||
// prevent (end-start) > zoomMin | |||
if (this.options.zoomMax != null) { | |||
var zoomMax = this.options.zoomMax.valueOf(); | |||
if (zoomMax < 0) { | |||
zoomMax = 0; | |||
} | |||
if ((newEnd - newStart) > zoomMax) { | |||
if ((this.end - this.start) < zoomMax) { | |||
// zoom to the maximum | |||
diff = ((newEnd - newStart) - zoomMax); | |||
newStart += diff / 2; | |||
newEnd -= diff / 2; | |||
} | |||
else { | |||
// ingore this action, we are already zoomed to the maximum | |||
newStart = this.start; | |||
newEnd = this.end; | |||
} | |||
} | |||
} | |||
var changed = (this.start != newStart || this.end != newEnd); | |||
this.start = newStart; | |||
this.end = newEnd; | |||
return changed; | |||
}; | |||
/** | |||
* Retrieve the current range. | |||
* @return {Object} An object with start and end properties | |||
*/ | |||
Range.prototype.getRange = function() { | |||
return { | |||
start: this.start, | |||
end: this.end | |||
}; | |||
}; | |||
/** | |||
* Calculate the conversion offset and factor for current range, based on | |||
* the provided width | |||
* @param {Number} width | |||
* @returns {{offset: number, factor: number}} conversion | |||
*/ | |||
Range.prototype.conversion = function (width) { | |||
var start = this.start; | |||
var end = this.end; | |||
return Range.conversion(this.start, this.end, width); | |||
}; | |||
/** | |||
* Static method to calculate the conversion offset and factor for a range, | |||
* based on the provided start, end, and width | |||
* @param {Number} start | |||
* @param {Number} end | |||
* @param {Number} width | |||
* @returns {{offset: number, factor: number}} conversion | |||
*/ | |||
Range.conversion = function (start, end, width) { | |||
if (width != 0 && (end - start != 0)) { | |||
return { | |||
offset: start, | |||
factor: width / (end - start) | |||
} | |||
} | |||
else { | |||
return { | |||
offset: 0, | |||
factor: 1 | |||
}; | |||
} | |||
}; | |||
/** | |||
* Start moving horizontally or vertically | |||
* @param {Event} event | |||
* @param {Object} listener Listener containing the component and params | |||
* @private | |||
*/ | |||
Range.prototype._onMouseDown = function(event, listener) { | |||
event = event || window.event; | |||
var params = listener.params; | |||
// only react on left mouse button down | |||
var leftButtonDown = event.which ? (event.which == 1) : (event.button == 1); | |||
if (!leftButtonDown) { | |||
return; | |||
} | |||
// get mouse position | |||
params.mouseX = util.getPageX(event); | |||
params.mouseY = util.getPageY(event); | |||
params.previousLeft = 0; | |||
params.previousOffset = 0; | |||
params.moved = false; | |||
params.start = this.start; | |||
params.end = this.end; | |||
var frame = listener.component.frame; | |||
if (frame) { | |||
frame.style.cursor = 'move'; | |||
} | |||
// add event listeners to handle moving the contents | |||
// we store the function onmousemove and onmouseup in the timeaxis, | |||
// so we can remove the eventlisteners lateron in the function onmouseup | |||
var me = this; | |||
if (!params.onMouseMove) { | |||
params.onMouseMove = function (event) { | |||
me._onMouseMove(event, listener); | |||
}; | |||
util.addEventListener(document, "mousemove", params.onMouseMove); | |||
} | |||
if (!params.onMouseUp) { | |||
params.onMouseUp = function (event) { | |||
me._onMouseUp(event, listener); | |||
}; | |||
util.addEventListener(document, "mouseup", params.onMouseUp); | |||
} | |||
util.preventDefault(event); | |||
}; | |||
/** | |||
* Perform moving operating. | |||
* This function activated from within the funcion TimeAxis._onMouseDown(). | |||
* @param {Event} event | |||
* @param {Object} listener | |||
* @private | |||
*/ | |||
Range.prototype._onMouseMove = function (event, listener) { | |||
event = event || window.event; | |||
var params = listener.params; | |||
// calculate change in mouse position | |||
var mouseX = util.getPageX(event); | |||
var mouseY = util.getPageY(event); | |||
if (params.mouseX == undefined) { | |||
params.mouseX = mouseX; | |||
} | |||
if (params.mouseY == undefined) { | |||
params.mouseY = mouseY; | |||
} | |||
var diffX = mouseX - params.mouseX; | |||
var diffY = mouseY - params.mouseY; | |||
var diff = (listener.direction == 'horizontal') ? diffX : diffY; | |||
// if mouse movement is big enough, register it as a "moved" event | |||
if (Math.abs(diff) >= 1) { | |||
params.moved = true; | |||
} | |||
var interval = (params.end - params.start); | |||
var width = (listener.direction == 'horizontal') ? | |||
listener.component.width : listener.component.height; | |||
var diffRange = -diff / width * interval; | |||
this._applyRange(params.start + diffRange, params.end + diffRange); | |||
// fire a rangechange event | |||
this._trigger('rangechange'); | |||
util.preventDefault(event); | |||
}; | |||
/** | |||
* Stop moving operating. | |||
* This function activated from within the function Range._onMouseDown(). | |||
* @param {event} event | |||
* @param {Object} listener | |||
* @private | |||
*/ | |||
Range.prototype._onMouseUp = function (event, listener) { | |||
event = event || window.event; | |||
var params = listener.params; | |||
if (listener.component.frame) { | |||
listener.component.frame.style.cursor = 'auto'; | |||
} | |||
// remove event listeners here, important for Safari | |||
if (params.onMouseMove) { | |||
util.removeEventListener(document, "mousemove", params.onMouseMove); | |||
params.onMouseMove = null; | |||
} | |||
if (params.onMouseUp) { | |||
util.removeEventListener(document, "mouseup", params.onMouseUp); | |||
params.onMouseUp = null; | |||
} | |||
//util.preventDefault(event); | |||
if (params.moved) { | |||
// fire a rangechanged event | |||
this._trigger('rangechanged'); | |||
} | |||
}; | |||
/** | |||
* Event handler for mouse wheel event, used to zoom | |||
* Code from http://adomas.org/javascript-mouse-wheel/ | |||
* @param {Event} event | |||
* @param {Object} listener | |||
* @private | |||
*/ | |||
Range.prototype._onMouseWheel = function(event, listener) { | |||
event = event || window.event; | |||
// retrieve delta | |||
var delta = 0; | |||
if (event.wheelDelta) { /* IE/Opera. */ | |||
delta = event.wheelDelta / 120; | |||
} else if (event.detail) { /* Mozilla case. */ | |||
// In Mozilla, sign of delta is different than in IE. | |||
// Also, delta is multiple of 3. | |||
delta = -event.detail / 3; | |||
} | |||
// If delta is nonzero, handle it. | |||
// Basically, delta is now positive if wheel was scrolled up, | |||
// and negative, if wheel was scrolled down. | |||
if (delta) { | |||
var me = this; | |||
var zoom = function () { | |||
// perform the zoom action. Delta is normally 1 or -1 | |||
var zoomFactor = delta / 5.0; | |||
var zoomAround = null; | |||
var frame = listener.component.frame; | |||
if (frame) { | |||
var size, conversion; | |||
if (listener.direction == 'horizontal') { | |||
size = listener.component.width; | |||
conversion = me.conversion(size); | |||
var frameLeft = util.getAbsoluteLeft(frame); | |||
var mouseX = util.getPageX(event); | |||
zoomAround = (mouseX - frameLeft) / conversion.factor + conversion.offset; | |||
} | |||
else { | |||
size = listener.component.height; | |||
conversion = me.conversion(size); | |||
var frameTop = util.getAbsoluteTop(frame); | |||
var mouseY = util.getPageY(event); | |||
zoomAround = ((frameTop + size - mouseY) - frameTop) / conversion.factor + conversion.offset; | |||
} | |||
} | |||
me.zoom(zoomFactor, zoomAround); | |||
}; | |||
zoom(); | |||
} | |||
// Prevent default actions caused by mouse wheel. | |||
// That might be ugly, but we handle scrolls somehow | |||
// anyway, so don't bother here... | |||
util.preventDefault(event); | |||
}; | |||
/** | |||
* Zoom the range the given zoomfactor in or out. Start and end date will | |||
* be adjusted, and the timeline will be redrawn. You can optionally give a | |||
* date around which to zoom. | |||
* For example, try zoomfactor = 0.1 or -0.1 | |||
* @param {Number} zoomFactor Zooming amount. Positive value will zoom in, | |||
* negative value will zoom out | |||
* @param {Number} zoomAround Value around which will be zoomed. Optional | |||
*/ | |||
Range.prototype.zoom = function(zoomFactor, zoomAround) { | |||
// if zoomAroundDate is not provided, take it half between start Date and end Date | |||
if (zoomAround == null) { | |||
zoomAround = (this.start + this.end) / 2; | |||
} | |||
// prevent zoom factor larger than 1 or smaller than -1 (larger than 1 will | |||
// result in a start>=end ) | |||
if (zoomFactor >= 1) { | |||
zoomFactor = 0.9; | |||
} | |||
if (zoomFactor <= -1) { | |||
zoomFactor = -0.9; | |||
} | |||
// adjust a negative factor such that zooming in with 0.1 equals zooming | |||
// out with a factor -0.1 | |||
if (zoomFactor < 0) { | |||
zoomFactor = zoomFactor / (1 + zoomFactor); | |||
} | |||
// zoom start and end relative to the zoomAround value | |||
var startDiff = (this.start - zoomAround); | |||
var endDiff = (this.end - zoomAround); | |||
// calculate new start and end | |||
var newStart = this.start - startDiff * zoomFactor; | |||
var newEnd = this.end - endDiff * zoomFactor; | |||
this.setRange(newStart, newEnd); | |||
}; | |||
/** | |||
* Move the range with a given factor to the left or right. Start and end | |||
* value will be adjusted. For example, try moveFactor = 0.1 or -0.1 | |||
* @param {Number} moveFactor Moving amount. Positive value will move right, | |||
* negative value will move left | |||
*/ | |||
Range.prototype.move = function(moveFactor) { | |||
// zoom start Date and end Date relative to the zoomAroundDate | |||
var diff = (this.end - this.start); | |||
// apply new values | |||
var newStart = this.start + diff * moveFactor; | |||
var newEnd = this.end + diff * moveFactor; | |||
// TODO: reckon with min and max range | |||
this.start = newStart; | |||
this.end = newEnd; | |||
}; |
@ -0,0 +1,157 @@ | |||
/** | |||
* @constructor Stack | |||
* Stacks items on top of each other. | |||
* @param {ItemSet} parent | |||
* @param {Object} [options] | |||
*/ | |||
function Stack (parent, options) { | |||
this.parent = parent; | |||
this.options = { | |||
order: function (a, b) { | |||
return (b.width - a.width) || (a.left - b.left); | |||
} | |||
}; | |||
this.ordered = []; // ordered items | |||
this.setOptions(options); | |||
} | |||
/** | |||
* Set options for the stack | |||
* @param {Object} options Available options: | |||
* {ItemSet} parent | |||
* {Number} margin | |||
* {function} order Stacking order | |||
*/ | |||
Stack.prototype.setOptions = function (options) { | |||
util.extend(this.options, options); | |||
// TODO: register on data changes at the connected parent 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. | |||
*/ | |||
Stack.prototype.update = function() { | |||
this._order(); | |||
this._stack(); | |||
}; | |||
/** | |||
* Order the items. The items are ordered by width first, and by left position | |||
* second. | |||
* If a custom order function has been provided via the options, then this will | |||
* be used. | |||
* @private | |||
*/ | |||
Stack.prototype._order = function() { | |||
var items = this.parent.items; | |||
if (!items) { | |||
throw new Error('Cannot stack items: parent does not contain items'); | |||
} | |||
// TODO: store the sorted items, to have less work later on | |||
var ordered = []; | |||
var index = 0; | |||
util.forEach(items, function (item, id) { | |||
ordered[index] = item; | |||
index++; | |||
}); | |||
//if a customer stack order function exists, use it. | |||
var order = this.options.order; | |||
if (!(typeof this.options.order === 'function')) { | |||
throw new Error('Option order must be a function'); | |||
} | |||
ordered.sort(order); | |||
this.ordered = ordered; | |||
}; | |||
/** | |||
* Adjust vertical positions of the events such that they don't overlap each | |||
* other. | |||
* @private | |||
*/ | |||
Stack.prototype._stack = function() { | |||
var i, | |||
iMax, | |||
ordered = this.ordered, | |||
options = this.options, | |||
axisOnTop = (options.orientation == 'top'), | |||
margin = options.margin && options.margin.item || 0; | |||
// 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); | |||
} | |||
}; | |||
/** | |||
* 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(items, itemIndex, itemStart, itemEnd, margin) { | |||
var collision = this.collision; | |||
// 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; | |||
} | |||
} | |||
} | |||
return null; | |||
}; | |||
/** | |||
* Test if the two provided items collide | |||
* The items must have parameters left, width, top, and height. | |||
* @param {Component} a The first item | |||
* @param {Component} b The second item | |||
* @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. | |||
* @return {boolean} true if a and b collide, else false | |||
*/ | |||
Stack.prototype.collision = function(a, b, margin) { | |||
return ((a.left - margin) < (b.left + b.width) && | |||
(a.left + a.width + margin) > b.left && | |||
(a.top - margin) < (b.top + b.height) && | |||
(a.top + a.height + margin) > b.top); | |||
}; |
@ -0,0 +1,450 @@ | |||
/** | |||
* @constructor TimeStep | |||
* The class TimeStep is an iterator for dates. You provide a start date and an | |||
* end date. The class itself determines the best scale (step size) based on the | |||
* provided start Date, end Date, and minimumStep. | |||
* | |||
* If minimumStep is provided, the step size is chosen as close as possible | |||
* to the minimumStep but larger than minimumStep. If minimumStep is not | |||
* provided, the scale is set to 1 DAY. | |||
* The minimumStep should correspond with the onscreen size of about 6 characters | |||
* | |||
* Alternatively, you can set a scale by hand. | |||
* After creation, you can initialize the class by executing first(). Then you | |||
* can iterate from the start date to the end date via next(). You can check if | |||
* the end date is reached with the function hasNext(). After each step, you can | |||
* retrieve the current date via getCurrent(). | |||
* The TimeStep has scales ranging from milliseconds, seconds, minutes, hours, | |||
* days, to years. | |||
* | |||
* Version: 1.2 | |||
* | |||
* @param {Date} [start] The start date, for example new Date(2010, 9, 21) | |||
* or new Date(2010, 9, 21, 23, 45, 00) | |||
* @param {Date} [end] The end date | |||
* @param {Number} [minimumStep] Optional. Minimum step size in milliseconds | |||
*/ | |||
TimeStep = function(start, end, minimumStep) { | |||
// variables | |||
this.current = new Date(); | |||
this._start = new Date(); | |||
this._end = new Date(); | |||
this.autoScale = true; | |||
this.scale = TimeStep.SCALE.DAY; | |||
this.step = 1; | |||
// initialize the range | |||
this.setRange(start, end, minimumStep); | |||
}; | |||
/// enum scale | |||
TimeStep.SCALE = { | |||
MILLISECOND: 1, | |||
SECOND: 2, | |||
MINUTE: 3, | |||
HOUR: 4, | |||
DAY: 5, | |||
WEEKDAY: 6, | |||
MONTH: 7, | |||
YEAR: 8 | |||
}; | |||
/** | |||
* Set a new range | |||
* If minimumStep is provided, the step size is chosen as close as possible | |||
* to the minimumStep but larger than minimumStep. If minimumStep is not | |||
* provided, the scale is set to 1 DAY. | |||
* The minimumStep should correspond with the onscreen size of about 6 characters | |||
* @param {Date} start The start date and time. | |||
* @param {Date} end The end date and time. | |||
* @param {int} [minimumStep] Optional. Minimum step size in milliseconds | |||
*/ | |||
TimeStep.prototype.setRange = function(start, end, minimumStep) { | |||
if (!(start instanceof Date) || !(end instanceof Date)) { | |||
//throw "No legal start or end date in method setRange"; | |||
return; | |||
} | |||
this._start = (start != undefined) ? new Date(start.valueOf()) : new Date(); | |||
this._end = (end != undefined) ? new Date(end.valueOf()) : new Date(); | |||
if (this.autoScale) { | |||
this.setMinimumStep(minimumStep); | |||
} | |||
}; | |||
/** | |||
* Set the range iterator to the start date. | |||
*/ | |||
TimeStep.prototype.first = function() { | |||
this.current = new Date(this._start.valueOf()); | |||
this.roundToMinor(); | |||
}; | |||
/** | |||
* Round the current date to the first minor date value | |||
* This must be executed once when the current date is set to start Date | |||
*/ | |||
TimeStep.prototype.roundToMinor = function() { | |||
// round to floor | |||
// IMPORTANT: we have no breaks in this switch! (this is no bug) | |||
//noinspection FallthroughInSwitchStatementJS | |||
switch (this.scale) { | |||
case TimeStep.SCALE.YEAR: | |||
this.current.setFullYear(this.step * Math.floor(this.current.getFullYear() / this.step)); | |||
this.current.setMonth(0); | |||
case TimeStep.SCALE.MONTH: this.current.setDate(1); | |||
case TimeStep.SCALE.DAY: // intentional fall through | |||
case TimeStep.SCALE.WEEKDAY: this.current.setHours(0); | |||
case TimeStep.SCALE.HOUR: this.current.setMinutes(0); | |||
case TimeStep.SCALE.MINUTE: this.current.setSeconds(0); | |||
case TimeStep.SCALE.SECOND: this.current.setMilliseconds(0); | |||
//case TimeStep.SCALE.MILLISECOND: // nothing to do for milliseconds | |||
} | |||
if (this.step != 1) { | |||
// round down to the first minor value that is a multiple of the current step size | |||
switch (this.scale) { | |||
case TimeStep.SCALE.MILLISECOND: this.current.setMilliseconds(this.current.getMilliseconds() - this.current.getMilliseconds() % this.step); break; | |||
case TimeStep.SCALE.SECOND: this.current.setSeconds(this.current.getSeconds() - this.current.getSeconds() % this.step); break; | |||
case TimeStep.SCALE.MINUTE: this.current.setMinutes(this.current.getMinutes() - this.current.getMinutes() % this.step); break; | |||
case TimeStep.SCALE.HOUR: this.current.setHours(this.current.getHours() - this.current.getHours() % this.step); break; | |||
case TimeStep.SCALE.WEEKDAY: // intentional fall through | |||
case TimeStep.SCALE.DAY: this.current.setDate((this.current.getDate()-1) - (this.current.getDate()-1) % this.step + 1); break; | |||
case TimeStep.SCALE.MONTH: this.current.setMonth(this.current.getMonth() - this.current.getMonth() % this.step); break; | |||
case TimeStep.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() - this.current.getFullYear() % this.step); break; | |||
default: break; | |||
} | |||
} | |||
}; | |||
/** | |||
* Check if the there is a next step | |||
* @return {boolean} true if the current date has not passed the end date | |||
*/ | |||
TimeStep.prototype.hasNext = function () { | |||
return (this.current.valueOf() <= this._end.valueOf()); | |||
}; | |||
/** | |||
* Do the next step | |||
*/ | |||
TimeStep.prototype.next = function() { | |||
var prev = this.current.valueOf(); | |||
// Two cases, needed to prevent issues with switching daylight savings | |||
// (end of March and end of October) | |||
if (this.current.getMonth() < 6) { | |||
switch (this.scale) { | |||
case TimeStep.SCALE.MILLISECOND: | |||
this.current = new Date(this.current.valueOf() + this.step); break; | |||
case TimeStep.SCALE.SECOND: this.current = new Date(this.current.valueOf() + this.step * 1000); break; | |||
case TimeStep.SCALE.MINUTE: this.current = new Date(this.current.valueOf() + this.step * 1000 * 60); break; | |||
case TimeStep.SCALE.HOUR: | |||
this.current = new Date(this.current.valueOf() + this.step * 1000 * 60 * 60); | |||
// in case of skipping an hour for daylight savings, adjust the hour again (else you get: 0h 5h 9h ... instead of 0h 4h 8h ...) | |||
var h = this.current.getHours(); | |||
this.current.setHours(h - (h % this.step)); | |||
break; | |||
case TimeStep.SCALE.WEEKDAY: // intentional fall through | |||
case TimeStep.SCALE.DAY: this.current.setDate(this.current.getDate() + this.step); break; | |||
case TimeStep.SCALE.MONTH: this.current.setMonth(this.current.getMonth() + this.step); break; | |||
case TimeStep.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() + this.step); break; | |||
default: break; | |||
} | |||
} | |||
else { | |||
switch (this.scale) { | |||
case TimeStep.SCALE.MILLISECOND: this.current = new Date(this.current.valueOf() + this.step); break; | |||
case TimeStep.SCALE.SECOND: this.current.setSeconds(this.current.getSeconds() + this.step); break; | |||
case TimeStep.SCALE.MINUTE: this.current.setMinutes(this.current.getMinutes() + this.step); break; | |||
case TimeStep.SCALE.HOUR: this.current.setHours(this.current.getHours() + this.step); break; | |||
case TimeStep.SCALE.WEEKDAY: // intentional fall through | |||
case TimeStep.SCALE.DAY: this.current.setDate(this.current.getDate() + this.step); break; | |||
case TimeStep.SCALE.MONTH: this.current.setMonth(this.current.getMonth() + this.step); break; | |||
case TimeStep.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() + this.step); break; | |||
default: break; | |||
} | |||
} | |||
if (this.step != 1) { | |||
// round down to the correct major value | |||
switch (this.scale) { | |||
case TimeStep.SCALE.MILLISECOND: if(this.current.getMilliseconds() < this.step) this.current.setMilliseconds(0); break; | |||
case TimeStep.SCALE.SECOND: if(this.current.getSeconds() < this.step) this.current.setSeconds(0); break; | |||
case TimeStep.SCALE.MINUTE: if(this.current.getMinutes() < this.step) this.current.setMinutes(0); break; | |||
case TimeStep.SCALE.HOUR: if(this.current.getHours() < this.step) this.current.setHours(0); break; | |||
case TimeStep.SCALE.WEEKDAY: // intentional fall through | |||
case TimeStep.SCALE.DAY: if(this.current.getDate() < this.step+1) this.current.setDate(1); break; | |||
case TimeStep.SCALE.MONTH: if(this.current.getMonth() < this.step) this.current.setMonth(0); break; | |||
case TimeStep.SCALE.YEAR: break; // nothing to do for year | |||
default: break; | |||
} | |||
} | |||
// safety mechanism: if current time is still unchanged, move to the end | |||
if (this.current.valueOf() == prev) { | |||
this.current = new Date(this._end.valueOf()); | |||
} | |||
}; | |||
/** | |||
* Get the current datetime | |||
* @return {Date} current The current date | |||
*/ | |||
TimeStep.prototype.getCurrent = function() { | |||
return this.current; | |||
}; | |||
/** | |||
* Set a custom scale. Autoscaling will be disabled. | |||
* For example setScale(SCALE.MINUTES, 5) will result | |||
* in minor steps of 5 minutes, and major steps of an hour. | |||
* | |||
* @param {TimeStep.SCALE} newScale | |||
* A scale. Choose from SCALE.MILLISECOND, | |||
* SCALE.SECOND, SCALE.MINUTE, SCALE.HOUR, | |||
* SCALE.WEEKDAY, SCALE.DAY, SCALE.MONTH, | |||
* SCALE.YEAR. | |||
* @param {Number} newStep A step size, by default 1. Choose for | |||
* example 1, 2, 5, or 10. | |||
*/ | |||
TimeStep.prototype.setScale = function(newScale, newStep) { | |||
this.scale = newScale; | |||
if (newStep > 0) { | |||
this.step = newStep; | |||
} | |||
this.autoScale = false; | |||
}; | |||
/** | |||
* Enable or disable autoscaling | |||
* @param {boolean} enable If true, autoascaling is set true | |||
*/ | |||
TimeStep.prototype.setAutoScale = function (enable) { | |||
this.autoScale = enable; | |||
}; | |||
/** | |||
* Automatically determine the scale that bests fits the provided minimum step | |||
* @param {Number} minimumStep The minimum step size in milliseconds | |||
*/ | |||
TimeStep.prototype.setMinimumStep = function(minimumStep) { | |||
if (minimumStep == undefined) { | |||
return; | |||
} | |||
var stepYear = (1000 * 60 * 60 * 24 * 30 * 12); | |||
var stepMonth = (1000 * 60 * 60 * 24 * 30); | |||
var stepDay = (1000 * 60 * 60 * 24); | |||
var stepHour = (1000 * 60 * 60); | |||
var stepMinute = (1000 * 60); | |||
var stepSecond = (1000); | |||
var stepMillisecond= (1); | |||
// find the smallest step that is larger than the provided minimumStep | |||
if (stepYear*1000 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 1000;} | |||
if (stepYear*500 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 500;} | |||
if (stepYear*100 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 100;} | |||
if (stepYear*50 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 50;} | |||
if (stepYear*10 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 10;} | |||
if (stepYear*5 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 5;} | |||
if (stepYear > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 1;} | |||
if (stepMonth*3 > minimumStep) {this.scale = TimeStep.SCALE.MONTH; this.step = 3;} | |||
if (stepMonth > minimumStep) {this.scale = TimeStep.SCALE.MONTH; this.step = 1;} | |||
if (stepDay*5 > minimumStep) {this.scale = TimeStep.SCALE.DAY; this.step = 5;} | |||
if (stepDay*2 > minimumStep) {this.scale = TimeStep.SCALE.DAY; this.step = 2;} | |||
if (stepDay > minimumStep) {this.scale = TimeStep.SCALE.DAY; this.step = 1;} | |||
if (stepDay/2 > minimumStep) {this.scale = TimeStep.SCALE.WEEKDAY; this.step = 1;} | |||
if (stepHour*4 > minimumStep) {this.scale = TimeStep.SCALE.HOUR; this.step = 4;} | |||
if (stepHour > minimumStep) {this.scale = TimeStep.SCALE.HOUR; this.step = 1;} | |||
if (stepMinute*15 > minimumStep) {this.scale = TimeStep.SCALE.MINUTE; this.step = 15;} | |||
if (stepMinute*10 > minimumStep) {this.scale = TimeStep.SCALE.MINUTE; this.step = 10;} | |||
if (stepMinute*5 > minimumStep) {this.scale = TimeStep.SCALE.MINUTE; this.step = 5;} | |||
if (stepMinute > minimumStep) {this.scale = TimeStep.SCALE.MINUTE; this.step = 1;} | |||
if (stepSecond*15 > minimumStep) {this.scale = TimeStep.SCALE.SECOND; this.step = 15;} | |||
if (stepSecond*10 > minimumStep) {this.scale = TimeStep.SCALE.SECOND; this.step = 10;} | |||
if (stepSecond*5 > minimumStep) {this.scale = TimeStep.SCALE.SECOND; this.step = 5;} | |||
if (stepSecond > minimumStep) {this.scale = TimeStep.SCALE.SECOND; this.step = 1;} | |||
if (stepMillisecond*200 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 200;} | |||
if (stepMillisecond*100 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 100;} | |||
if (stepMillisecond*50 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 50;} | |||
if (stepMillisecond*10 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 10;} | |||
if (stepMillisecond*5 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 5;} | |||
if (stepMillisecond > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 1;} | |||
}; | |||
/** | |||
* Snap a date to a rounded value. The snap intervals are dependent on the | |||
* current scale and step. | |||
* @param {Date} date the date to be snapped | |||
*/ | |||
TimeStep.prototype.snap = function(date) { | |||
if (this.scale == TimeStep.SCALE.YEAR) { | |||
var year = date.getFullYear() + Math.round(date.getMonth() / 12); | |||
date.setFullYear(Math.round(year / this.step) * this.step); | |||
date.setMonth(0); | |||
date.setDate(0); | |||
date.setHours(0); | |||
date.setMinutes(0); | |||
date.setSeconds(0); | |||
date.setMilliseconds(0); | |||
} | |||
else if (this.scale == TimeStep.SCALE.MONTH) { | |||
if (date.getDate() > 15) { | |||
date.setDate(1); | |||
date.setMonth(date.getMonth() + 1); | |||
// important: first set Date to 1, after that change the month. | |||
} | |||
else { | |||
date.setDate(1); | |||
} | |||
date.setHours(0); | |||
date.setMinutes(0); | |||
date.setSeconds(0); | |||
date.setMilliseconds(0); | |||
} | |||
else if (this.scale == TimeStep.SCALE.DAY || | |||
this.scale == TimeStep.SCALE.WEEKDAY) { | |||
//noinspection FallthroughInSwitchStatementJS | |||
switch (this.step) { | |||
case 5: | |||
case 2: | |||
date.setHours(Math.round(date.getHours() / 24) * 24); break; | |||
default: | |||
date.setHours(Math.round(date.getHours() / 12) * 12); break; | |||
} | |||
date.setMinutes(0); | |||
date.setSeconds(0); | |||
date.setMilliseconds(0); | |||
} | |||
else if (this.scale == TimeStep.SCALE.HOUR) { | |||
switch (this.step) { | |||
case 4: | |||
date.setMinutes(Math.round(date.getMinutes() / 60) * 60); break; | |||
default: | |||
date.setMinutes(Math.round(date.getMinutes() / 30) * 30); break; | |||
} | |||
date.setSeconds(0); | |||
date.setMilliseconds(0); | |||
} else if (this.scale == TimeStep.SCALE.MINUTE) { | |||
//noinspection FallthroughInSwitchStatementJS | |||
switch (this.step) { | |||
case 15: | |||
case 10: | |||
date.setMinutes(Math.round(date.getMinutes() / 5) * 5); | |||
date.setSeconds(0); | |||
break; | |||
case 5: | |||
date.setSeconds(Math.round(date.getSeconds() / 60) * 60); break; | |||
default: | |||
date.setSeconds(Math.round(date.getSeconds() / 30) * 30); break; | |||
} | |||
date.setMilliseconds(0); | |||
} | |||
else if (this.scale == TimeStep.SCALE.SECOND) { | |||
//noinspection FallthroughInSwitchStatementJS | |||
switch (this.step) { | |||
case 15: | |||
case 10: | |||
date.setSeconds(Math.round(date.getSeconds() / 5) * 5); | |||
date.setMilliseconds(0); | |||
break; | |||
case 5: | |||
date.setMilliseconds(Math.round(date.getMilliseconds() / 1000) * 1000); break; | |||
default: | |||
date.setMilliseconds(Math.round(date.getMilliseconds() / 500) * 500); break; | |||
} | |||
} | |||
else if (this.scale == TimeStep.SCALE.MILLISECOND) { | |||
var step = this.step > 5 ? this.step / 2 : 1; | |||
date.setMilliseconds(Math.round(date.getMilliseconds() / step) * step); | |||
} | |||
}; | |||
/** | |||
* Check if the current value is a major value (for example when the step | |||
* is DAY, a major value is each first day of the MONTH) | |||
* @return {boolean} true if current date is major, else false. | |||
*/ | |||
TimeStep.prototype.isMajor = function() { | |||
switch (this.scale) { | |||
case TimeStep.SCALE.MILLISECOND: | |||
return (this.current.getMilliseconds() == 0); | |||
case TimeStep.SCALE.SECOND: | |||
return (this.current.getSeconds() == 0); | |||
case TimeStep.SCALE.MINUTE: | |||
return (this.current.getHours() == 0) && (this.current.getMinutes() == 0); | |||
// Note: this is no bug. Major label is equal for both minute and hour scale | |||
case TimeStep.SCALE.HOUR: | |||
return (this.current.getHours() == 0); | |||
case TimeStep.SCALE.WEEKDAY: // intentional fall through | |||
case TimeStep.SCALE.DAY: | |||
return (this.current.getDate() == 1); | |||
case TimeStep.SCALE.MONTH: | |||
return (this.current.getMonth() == 0); | |||
case TimeStep.SCALE.YEAR: | |||
return false; | |||
default: | |||
return false; | |||
} | |||
}; | |||
/** | |||
* Returns formatted text for the minor axislabel, depending on the current | |||
* date and the scale. For example when scale is MINUTE, the current time is | |||
* formatted as "hh:mm". | |||
* @param {Date} [date] custom date. if not provided, current date is taken | |||
*/ | |||
TimeStep.prototype.getLabelMinor = function(date) { | |||
if (date == undefined) { | |||
date = this.current; | |||
} | |||
switch (this.scale) { | |||
case TimeStep.SCALE.MILLISECOND: return moment(date).format('SSS'); | |||
case TimeStep.SCALE.SECOND: return moment(date).format('s'); | |||
case TimeStep.SCALE.MINUTE: return moment(date).format('HH:mm'); | |||
case TimeStep.SCALE.HOUR: return moment(date).format('HH:mm'); | |||
case TimeStep.SCALE.WEEKDAY: return moment(date).format('ddd D'); | |||
case TimeStep.SCALE.DAY: return moment(date).format('D'); | |||
case TimeStep.SCALE.MONTH: return moment(date).format('MMM'); | |||
case TimeStep.SCALE.YEAR: return moment(date).format('YYYY'); | |||
default: return ''; | |||
} | |||
}; | |||
/** | |||
* Returns formatted text for the major axislabel, depending on the current | |||
* date and the scale. For example when scale is MINUTE, the major scale is | |||
* hours, and the hour will be formatted as "hh". | |||
* @param {Date} [date] custom date. if not provided, current date is taken | |||
*/ | |||
TimeStep.prototype.getLabelMajor = function(date) { | |||
if (date == undefined) { | |||
date = this.current; | |||
} | |||
//noinspection FallthroughInSwitchStatementJS | |||
switch (this.scale) { | |||
case TimeStep.SCALE.MILLISECOND:return moment(date).format('HH:mm:ss'); | |||
case TimeStep.SCALE.SECOND: return moment(date).format('D MMMM HH:mm'); | |||
case TimeStep.SCALE.MINUTE: | |||
case TimeStep.SCALE.HOUR: return moment(date).format('ddd D MMMM'); | |||
case TimeStep.SCALE.WEEKDAY: | |||
case TimeStep.SCALE.DAY: return moment(date).format('MMMM YYYY'); | |||
case TimeStep.SCALE.MONTH: return moment(date).format('YYYY'); | |||
case TimeStep.SCALE.YEAR: return ''; | |||
default: return ''; | |||
} | |||
}; |
@ -0,0 +1,732 @@ | |||
// create namespace | |||
var util = {}; | |||
/** | |||
* Test whether given object is a number | |||
* @param {*} object | |||
* @return {Boolean} isNumber | |||
*/ | |||
util.isNumber = function isNumber(object) { | |||
return (object instanceof Number || typeof object == 'number'); | |||
}; | |||
/** | |||
* Test whether given object is a string | |||
* @param {*} object | |||
* @return {Boolean} isString | |||
*/ | |||
util.isString = function isString(object) { | |||
return (object instanceof String || typeof object == 'string'); | |||
}; | |||
/** | |||
* Test whether given object is a Date, or a String containing a Date | |||
* @param {Date | String} object | |||
* @return {Boolean} isDate | |||
*/ | |||
util.isDate = function isDate(object) { | |||
if (object instanceof Date) { | |||
return true; | |||
} | |||
else if (util.isString(object)) { | |||
// test whether this string contains a date | |||
var match = ASPDateRegex.exec(object); | |||
if (match) { | |||
return true; | |||
} | |||
else if (!isNaN(Date.parse(object))) { | |||
return true; | |||
} | |||
} | |||
return false; | |||
}; | |||
/** | |||
* Test whether given object is an instance of google.visualization.DataTable | |||
* @param {*} object | |||
* @return {Boolean} isDataTable | |||
*/ | |||
util.isDataTable = function isDataTable(object) { | |||
return (typeof (google) !== 'undefined') && | |||
(google.visualization) && | |||
(google.visualization.DataTable) && | |||
(object instanceof google.visualization.DataTable); | |||
}; | |||
/** | |||
* Create a semi UUID | |||
* source: http://stackoverflow.com/a/105074/1262753 | |||
* @return {String} uuid | |||
*/ | |||
util.randomUUID = function randomUUID () { | |||
var S4 = function () { | |||
return Math.floor( | |||
Math.random() * 0x10000 /* 65536 */ | |||
).toString(16); | |||
}; | |||
return ( | |||
S4() + S4() + '-' + | |||
S4() + '-' + | |||
S4() + '-' + | |||
S4() + '-' + | |||
S4() + S4() + S4() | |||
); | |||
}; | |||
/** | |||
* Extend object a with the properties of object b | |||
* @param {Object} a | |||
* @param {Object} b | |||
* @return {Object} a | |||
*/ | |||
util.extend = function (a, b) { | |||
for (var prop in b) { | |||
if (b.hasOwnProperty(prop)) { | |||
a[prop] = b[prop]; | |||
} | |||
} | |||
return a; | |||
}; | |||
/** | |||
* Cast an object to another type | |||
* @param {Boolean | Number | String | Date | Null | undefined} object | |||
* @param {String | function | undefined} type Name of the type or a cast | |||
* function. Available types: | |||
* 'Boolean', 'Number', 'String', | |||
* 'Date', ISODate', 'ASPDate'. | |||
* @return {*} object | |||
* @throws Error | |||
*/ | |||
util.cast = function cast(object, type) { | |||
if (object === undefined) { | |||
return undefined; | |||
} | |||
if (object === null) { | |||
return null; | |||
} | |||
if (!type) { | |||
return object; | |||
} | |||
if (typeof type == 'function') { | |||
return type(object); | |||
} | |||
//noinspection FallthroughInSwitchStatementJS | |||
switch (type) { | |||
case 'boolean': | |||
case 'Boolean': | |||
return Boolean(object); | |||
case 'number': | |||
case 'Number': | |||
return Number(object); | |||
case 'string': | |||
case 'String': | |||
return String(object); | |||
case 'Date': | |||
if (util.isNumber(object)) { | |||
return new Date(object); | |||
} | |||
if (object instanceof Date) { | |||
return new Date(object.valueOf()); | |||
} | |||
if (util.isString(object)) { | |||
// parse ASP.Net Date pattern, | |||
// for example '/Date(1198908717056)/' or '/Date(1198908717056-0700)/' | |||
// code from http://momentjs.com/ | |||
var match = ASPDateRegex.exec(object); | |||
if (match) { | |||
return new Date(Number(match[1])); | |||
} | |||
else { | |||
return new Date(object); | |||
} | |||
} | |||
else { | |||
throw new Error( | |||
'Cannot cast object of type ' + util.getType(object) + | |||
' to type Date'); | |||
} | |||
case 'ISODate': | |||
if (object instanceof Date) { | |||
return object.toISOString(); | |||
} | |||
else if (util.isNumber(object) || util.isString(Object)) { | |||
return (new Date(object)).toISOString() | |||
} | |||
else { | |||
throw new Error( | |||
'Cannot cast object of type ' + util.getType(object) + | |||
' to type ISODate'); | |||
} | |||
case 'ASPDate': | |||
if (object instanceof Date) { | |||
return '/Date(' + object.valueOf() + ')/'; | |||
} | |||
else if (util.isNumber(object) || util.isString(Object)) { | |||
return '/Date(' + (new Date(object)).valueOf() + ')/'; | |||
} | |||
else { | |||
throw new Error( | |||
'Cannot cast object of type ' + util.getType(object) + | |||
' to type ASPDate'); | |||
} | |||
default: | |||
throw new Error('Cannot cast object of type ' + util.getType(object) + | |||
' to type "' + type + '"'); | |||
} | |||
}; | |||
var ASPDateRegex = /^\/?Date\((\-?\d+)/i; | |||
/** | |||
* Get the type of an object | |||
* @param {*} object | |||
* @return {String} type | |||
*/ | |||
util.getType = function getType(object) { | |||
var type = typeof object; | |||
if (type == 'object') { | |||
if (object == null) { | |||
return 'null'; | |||
} | |||
if (object && object.constructor && object.constructor.name) { | |||
return object.constructor.name; | |||
} | |||
} | |||
return type; | |||
}; | |||
/** | |||
* Retrieve the absolute left value of a DOM element | |||
* @param {Element} elem A dom element, for example a div | |||
* @return {number} left The absolute left position of this element | |||
* in the browser page. | |||
*/ | |||
util.getAbsoluteLeft = function getAbsoluteLeft (elem) { | |||
var doc = document.documentElement; | |||
var body = document.body; | |||
var left = elem.offsetLeft; | |||
var e = elem.offsetParent; | |||
while (e != null && e != body && e != doc) { | |||
left += e.offsetLeft; | |||
left -= e.scrollLeft; | |||
e = e.offsetParent; | |||
} | |||
return left; | |||
}; | |||
/** | |||
* Retrieve the absolute top value of a DOM element | |||
* @param {Element} elem A dom element, for example a div | |||
* @return {number} top The absolute top position of this element | |||
* in the browser page. | |||
*/ | |||
util.getAbsoluteTop = function getAbsoluteTop (elem) { | |||
var doc = document.documentElement; | |||
var body = document.body; | |||
var top = elem.offsetTop; | |||
var e = elem.offsetParent; | |||
while (e != null && e != body && e != doc) { | |||
top += e.offsetTop; | |||
top -= e.scrollTop; | |||
e = e.offsetParent; | |||
} | |||
return top; | |||
}; | |||
/** | |||
* Get the absolute, vertical mouse position from an event. | |||
* @param {Event} event | |||
* @return {Number} pageY | |||
*/ | |||
util.getPageY = function getPageY (event) { | |||
if ('pageY' in event) { | |||
return event.pageY; | |||
} | |||
else { | |||
var clientY; | |||
if (('targetTouches' in event) && event.targetTouches.length) { | |||
clientY = event.targetTouches[0].clientY; | |||
} | |||
else { | |||
clientY = event.clientY; | |||
} | |||
var doc = document.documentElement; | |||
var body = document.body; | |||
return clientY + | |||
( doc && doc.scrollTop || body && body.scrollTop || 0 ) - | |||
( doc && doc.clientTop || body && body.clientTop || 0 ); | |||
} | |||
}; | |||
/** | |||
* Get the absolute, horizontal mouse position from an event. | |||
* @param {Event} event | |||
* @return {Number} pageX | |||
*/ | |||
util.getPageX = function getPageX (event) { | |||
if ('pageY' in event) { | |||
return event.pageX; | |||
} | |||
else { | |||
var clientX; | |||
if (('targetTouches' in event) && event.targetTouches.length) { | |||
clientX = event.targetTouches[0].clientX; | |||
} | |||
else { | |||
clientX = event.clientX; | |||
} | |||
var doc = document.documentElement; | |||
var body = document.body; | |||
return clientX + | |||
( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - | |||
( doc && doc.clientLeft || body && body.clientLeft || 0 ); | |||
} | |||
}; | |||
/** | |||
* add a className to the given elements style | |||
* @param {Element} elem | |||
* @param {String} className | |||
*/ | |||
util.addClassName = function addClassName(elem, className) { | |||
var classes = elem.className.split(' '); | |||
if (classes.indexOf(className) == -1) { | |||
classes.push(className); // add the class to the array | |||
elem.className = classes.join(' '); | |||
} | |||
}; | |||
/** | |||
* add a className to the given elements style | |||
* @param {Element} elem | |||
* @param {String} className | |||
*/ | |||
util.removeClassName = function removeClassname(elem, className) { | |||
var classes = elem.className.split(' '); | |||
var index = classes.indexOf(className); | |||
if (index != -1) { | |||
classes.splice(index, 1); // remove the class from the array | |||
elem.className = classes.join(' '); | |||
} | |||
}; | |||
/** | |||
* For each method for both arrays and objects. | |||
* In case of an array, the built-in Array.forEach() is applied. | |||
* In case of an Object, the method loops over all properties of the object. | |||
* @param {Object | Array} object An Object or Array | |||
* @param {function} callback Callback method, called for each item in | |||
* the object or array with three parameters: | |||
* callback(value, index, object) | |||
*/ | |||
util.forEach = function forEach (object, callback) { | |||
if (object instanceof Array) { | |||
// array | |||
object.forEach(callback); | |||
} | |||
else { | |||
// object | |||
for (var key in object) { | |||
if (object.hasOwnProperty(key)) { | |||
callback(object[key], key, object); | |||
} | |||
} | |||
} | |||
}; | |||
/** | |||
* Update a property in an object | |||
* @param {Object} object | |||
* @param {String} key | |||
* @param {*} value | |||
* @return {Boolean} changed | |||
*/ | |||
util.updateProperty = function updateProp (object, key, value) { | |||
if (object[key] !== value) { | |||
object[key] = value; | |||
return true; | |||
} | |||
else { | |||
return false; | |||
} | |||
}; | |||
/** | |||
* Add and event listener. Works for all browsers | |||
* @param {Element} element An html element | |||
* @param {string} action The action, for example "click", | |||
* without the prefix "on" | |||
* @param {function} listener The callback function to be executed | |||
* @param {boolean} [useCapture] | |||
*/ | |||
util.addEventListener = function addEventListener(element, action, listener, useCapture) { | |||
if (element.addEventListener) { | |||
if (useCapture === undefined) | |||
useCapture = false; | |||
if (action === "mousewheel" && navigator.userAgent.indexOf("Firefox") >= 0) { | |||
action = "DOMMouseScroll"; // For Firefox | |||
} | |||
element.addEventListener(action, listener, useCapture); | |||
} else { | |||
element.attachEvent("on" + action, listener); // IE browsers | |||
} | |||
}; | |||
/** | |||
* Remove an event listener from an element | |||
* @param {Element} element An html dom element | |||
* @param {string} action The name of the event, for example "mousedown" | |||
* @param {function} listener The listener function | |||
* @param {boolean} [useCapture] | |||
*/ | |||
util.removeEventListener = function removeEventListener(element, action, listener, useCapture) { | |||
if (element.removeEventListener) { | |||
// non-IE browsers | |||
if (useCapture === undefined) | |||
useCapture = false; | |||
if (action === "mousewheel" && navigator.userAgent.indexOf("Firefox") >= 0) { | |||
action = "DOMMouseScroll"; // For Firefox | |||
} | |||
element.removeEventListener(action, listener, useCapture); | |||
} else { | |||
// IE browsers | |||
element.detachEvent("on" + action, listener); | |||
} | |||
}; | |||
/** | |||
* Get HTML element which is the target of the event | |||
* @param {Event} event | |||
* @return {Element} target element | |||
*/ | |||
util.getTarget = function getTarget(event) { | |||
// code from http://www.quirksmode.org/js/events_properties.html | |||
if (!event) { | |||
event = window.event; | |||
} | |||
var target; | |||
if (event.target) { | |||
target = event.target; | |||
} | |||
else if (event.srcElement) { | |||
target = event.srcElement; | |||
} | |||
if (target.nodeType != undefined && target.nodeType == 3) { | |||
// defeat Safari bug | |||
target = target.parentNode; | |||
} | |||
return target; | |||
}; | |||
/** | |||
* Stop event propagation | |||
*/ | |||
util.stopPropagation = function stopPropagation(event) { | |||
if (!event) | |||
event = window.event; | |||
if (event.stopPropagation) { | |||
event.stopPropagation(); // non-IE browsers | |||
} | |||
else { | |||
event.cancelBubble = true; // IE browsers | |||
} | |||
}; | |||
/** | |||
* Cancels the event if it is cancelable, without stopping further propagation of the event. | |||
*/ | |||
util.preventDefault = function preventDefault (event) { | |||
if (!event) | |||
event = window.event; | |||
if (event.preventDefault) { | |||
event.preventDefault(); // non-IE browsers | |||
} | |||
else { | |||
event.returnValue = false; // IE browsers | |||
} | |||
}; | |||
util.option = {}; | |||
/** | |||
* Cast a value as boolean | |||
* @param {Boolean | function | undefined} value | |||
* @param {Boolean} [defaultValue] | |||
* @returns {Boolean} bool | |||
*/ | |||
util.option.asBoolean = function (value, defaultValue) { | |||
if (typeof value == 'function') { | |||
value = value(); | |||
} | |||
if (value != null) { | |||
return (value != false); | |||
} | |||
return defaultValue || null; | |||
}; | |||
/** | |||
* Cast a value as string | |||
* @param {String | function | undefined} value | |||
* @param {String} [defaultValue] | |||
* @returns {String} str | |||
*/ | |||
util.option.asString = function (value, defaultValue) { | |||
if (typeof value == 'function') { | |||
value = value(); | |||
} | |||
if (value != null) { | |||
return String(value); | |||
} | |||
return defaultValue || null; | |||
}; | |||
/** | |||
* Cast a size or location in pixels or a percentage | |||
* @param {String | Number | function | undefined} value | |||
* @param {String} [defaultValue] | |||
* @returns {String} size | |||
*/ | |||
util.option.asSize = function (value, defaultValue) { | |||
if (typeof value == 'function') { | |||
value = value(); | |||
} | |||
if (util.isString(value)) { | |||
return value; | |||
} | |||
else if (util.isNumber(value)) { | |||
return value + 'px'; | |||
} | |||
else { | |||
return defaultValue || null; | |||
} | |||
}; | |||
/** | |||
* Cast a value as DOM element | |||
* @param {HTMLElement | function | undefined} value | |||
* @param {HTMLElement} [defaultValue] | |||
* @returns {HTMLElement | null} dom | |||
*/ | |||
util.option.asElement = function (value, defaultValue) { | |||
if (typeof value == 'function') { | |||
value = value(); | |||
} | |||
return value || defaultValue || null; | |||
}; | |||
// Internet Explorer 8 and older does not support Array.indexOf, so we define | |||
// it here in that case. | |||
// http://soledadpenades.com/2007/05/17/arrayindexof-in-internet-explorer/ | |||
if(!Array.prototype.indexOf) { | |||
Array.prototype.indexOf = function(obj){ | |||
for(var i = 0; i < this.length; i++){ | |||
if(this[i] == obj){ | |||
return i; | |||
} | |||
} | |||
return -1; | |||
}; | |||
try { | |||
console.log("Warning: Ancient browser detected. Please update your browser"); | |||
} | |||
catch (err) { | |||
} | |||
} | |||
// Internet Explorer 8 and older does not support Array.forEach, so we define | |||
// it here in that case. | |||
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/forEach | |||
if (!Array.prototype.forEach) { | |||
Array.prototype.forEach = function(fn, scope) { | |||
for(var i = 0, len = this.length; i < len; ++i) { | |||
fn.call(scope || this, this[i], i, this); | |||
} | |||
} | |||
} | |||
// Internet Explorer 8 and older does not support Array.map, so we define it | |||
// here in that case. | |||
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/map | |||
// Production steps of ECMA-262, Edition 5, 15.4.4.19 | |||
// Reference: http://es5.github.com/#x15.4.4.19 | |||
if (!Array.prototype.map) { | |||
Array.prototype.map = function(callback, thisArg) { | |||
var T, A, k; | |||
if (this == null) { | |||
throw new TypeError(" this is null or not defined"); | |||
} | |||
// 1. Let O be the result of calling ToObject passing the |this| value as the argument. | |||
var O = Object(this); | |||
// 2. Let lenValue be the result of calling the Get internal method of O with the argument "length". | |||
// 3. Let len be ToUint32(lenValue). | |||
var len = O.length >>> 0; | |||
// 4. If IsCallable(callback) is false, throw a TypeError exception. | |||
// See: http://es5.github.com/#x9.11 | |||
if (typeof callback !== "function") { | |||
throw new TypeError(callback + " is not a function"); | |||
} | |||
// 5. If thisArg was supplied, let T be thisArg; else let T be undefined. | |||
if (thisArg) { | |||
T = thisArg; | |||
} | |||
// 6. Let A be a new array created as if by the expression new Array(len) where Array is | |||
// the standard built-in constructor with that name and len is the value of len. | |||
A = new Array(len); | |||
// 7. Let k be 0 | |||
k = 0; | |||
// 8. Repeat, while k < len | |||
while(k < len) { | |||
var kValue, mappedValue; | |||
// a. Let Pk be ToString(k). | |||
// This is implicit for LHS operands of the in operator | |||
// b. Let kPresent be the result of calling the HasProperty internal method of O with argument Pk. | |||
// This step can be combined with c | |||
// c. If kPresent is true, then | |||
if (k in O) { | |||
// i. Let kValue be the result of calling the Get internal method of O with argument Pk. | |||
kValue = O[ k ]; | |||
// ii. Let mappedValue be the result of calling the Call internal method of callback | |||
// with T as the this value and argument list containing kValue, k, and O. | |||
mappedValue = callback.call(T, kValue, k, O); | |||
// iii. Call the DefineOwnProperty internal method of A with arguments | |||
// Pk, Property Descriptor {Value: mappedValue, : true, Enumerable: true, Configurable: true}, | |||
// and false. | |||
// In browsers that support Object.defineProperty, use the following: | |||
// Object.defineProperty(A, Pk, { value: mappedValue, writable: true, enumerable: true, configurable: true }); | |||
// For best browser support, use the following: | |||
A[ k ] = mappedValue; | |||
} | |||
// d. Increase k by 1. | |||
k++; | |||
} | |||
// 9. return A | |||
return A; | |||
}; | |||
} | |||
// Internet Explorer 8 and older does not support Array.filter, so we define it | |||
// here in that case. | |||
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/filter | |||
if (!Array.prototype.filter) { | |||
Array.prototype.filter = function(fun /*, thisp */) { | |||
"use strict"; | |||
if (this == null) { | |||
throw new TypeError(); | |||
} | |||
var t = Object(this); | |||
var len = t.length >>> 0; | |||
if (typeof fun != "function") { | |||
throw new TypeError(); | |||
} | |||
var res = []; | |||
var thisp = arguments[1]; | |||
for (var i = 0; i < len; i++) { | |||
if (i in t) { | |||
var val = t[i]; // in case fun mutates this | |||
if (fun.call(thisp, val, i, t)) | |||
res.push(val); | |||
} | |||
} | |||
return res; | |||
}; | |||
} | |||
// Internet Explorer 8 and older does not support Object.keys, so we define it | |||
// here in that case. | |||
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/keys | |||
if (!Object.keys) { | |||
Object.keys = (function () { | |||
var hasOwnProperty = Object.prototype.hasOwnProperty, | |||
hasDontEnumBug = !({toString: null}).propertyIsEnumerable('toString'), | |||
dontEnums = [ | |||
'toString', | |||
'toLocaleString', | |||
'valueOf', | |||
'hasOwnProperty', | |||
'isPrototypeOf', | |||
'propertyIsEnumerable', | |||
'constructor' | |||
], | |||
dontEnumsLength = dontEnums.length; | |||
return function (obj) { | |||
if (typeof obj !== 'object' && typeof obj !== 'function' || obj === null) { | |||
throw new TypeError('Object.keys called on non-object'); | |||
} | |||
var result = []; | |||
for (var prop in obj) { | |||
if (hasOwnProperty.call(obj, prop)) result.push(prop); | |||
} | |||
if (hasDontEnumBug) { | |||
for (var i=0; i < dontEnumsLength; i++) { | |||
if (hasOwnProperty.call(obj, dontEnums[i])) result.push(dontEnums[i]); | |||
} | |||
} | |||
return result; | |||
} | |||
})() | |||
} |
@ -0,0 +1,118 @@ | |||
/** | |||
* Create a timeline visualization | |||
* @param {HTMLElement} container | |||
* @param {DataSet | Array | DataTable} [data] | |||
* @param {Object} [options] See Timeline.setOptions for the available options. | |||
* @constructor | |||
*/ | |||
function Timeline (container, data, options) { | |||
var me = this; | |||
this.options = { | |||
orientation: 'bottom', | |||
zoomMin: 10, // milliseconds | |||
zoomMax: 1000 * 60 * 60 * 24 * 365 * 10000, // milliseconds | |||
moveable: true, | |||
zoomable: true | |||
}; | |||
// controller | |||
this.controller = new Controller(); | |||
// main panel | |||
if (!container) { | |||
throw new Error('No container element provided'); | |||
} | |||
this.main = new RootPanel(container, { | |||
autoResize: false, | |||
height: function () { | |||
return me.timeaxis.height + me.itemset.height; | |||
} | |||
}); | |||
this.controller.add(this.main); | |||
// range | |||
var now = moment().minutes(0).seconds(0).milliseconds(0); | |||
var start = options.start && options.start.valueOf() || now.clone().add('days', -3).valueOf(); | |||
var end = options.end && options.end.valueOf() || moment(start).clone().add('days', 7).valueOf(); | |||
// TODO: if start and end are not provided, calculate range from the dataset | |||
this.range = new Range({ | |||
start: start, | |||
end: end | |||
}); | |||
// TODO: reckon with options moveable and zoomable | |||
this.range.subscribe(this.main, 'move', 'horizontal'); | |||
this.range.subscribe(this.main, 'zoom', 'horizontal'); | |||
this.range.on('rangechange', function () { | |||
// TODO: fix the delay in reflow/repaint, does not feel snappy | |||
me.controller.requestReflow(); | |||
}); | |||
this.range.on('rangechanged', function () { | |||
me.controller.requestReflow(); | |||
}); | |||
// TODO: put the listeners in setOptions, be able to dynamically change with options moveable and zoomable | |||
// time axis | |||
this.timeaxis = new TimeAxis(this.main, null, { | |||
orientation: this.options.orientation, | |||
range: this.range | |||
}); | |||
this.timeaxis.setRange(this.range); | |||
this.controller.add(this.timeaxis); | |||
// items panel | |||
this.itemset = new ItemSet(this.main, [this.timeaxis], { | |||
orientation: this.options.orientation, | |||
range: this.range, | |||
data: data | |||
}); | |||
this.itemset.setRange(this.range); | |||
if (data) { | |||
this.setData(data); | |||
} | |||
this.controller.add(this.itemset); | |||
this.setOptions(options); | |||
} | |||
/** | |||
* Set options | |||
* @param {Object} options TODO: describe the available options | |||
*/ | |||
Timeline.prototype.setOptions = function (options) { | |||
util.extend(this.options, options); | |||
// update options the timeaxis | |||
this.timeaxis.setOptions(this.options); | |||
// update options for the range | |||
this.range.setOptions(this.options); | |||
// update options the itemset | |||
var top, | |||
me = this; | |||
if (this.options.orientation == 'top') { | |||
top = function () { | |||
return me.timeaxis.height; | |||
} | |||
} | |||
else { | |||
top = function () { | |||
return me.main.height - me.timeaxis.height - me.itemset.height; | |||
} | |||
} | |||
this.itemset.setOptions({ | |||
orientation: this.options.orientation, | |||
top: top | |||
}); | |||
this.controller.repaint(); | |||
}; | |||
/** | |||
* Set data | |||
* @param {DataSet | Array | DataTable} data | |||
*/ | |||
Timeline.prototype.setData = function(data) { | |||
this.itemset.setData(data); | |||
}; |
@ -0,0 +1,78 @@ | |||
<!DOCTYPE HTML> | |||
<html> | |||
<head> | |||
<title></title> | |||
<script src="../lib/moment.min.js"></script> | |||
<script src="../src/util.js"></script> | |||
<script src="../src/events.js"></script> | |||
<script src="../src/range.js"></script> | |||
<script src="../src/timestep.js"></script> | |||
<script src="../src/dataset.js"></script> | |||
<script type="text/javascript" src="http://www.google.com/jsapi"></script> | |||
</head> | |||
<body> | |||
<script> | |||
var dataset = new DataSet({ | |||
fieldId: 'id', | |||
fieldTypes: { | |||
start: 'ISODate' | |||
} | |||
}); | |||
// create an anonymous event listener | |||
dataset.subscribe('*', function (event, params, id) { | |||
console.log('anonymous listener ', event, params, id); | |||
}); | |||
// create a named event listener | |||
var entityId = '123'; | |||
dataset.subscribe('*', function (event, params, id) { | |||
console.log('named listener ', event, params, id); | |||
}, entityId); | |||
// anonymous put | |||
dataset.add([ | |||
{id: 1, content: 'item 1', start: new Date()}, | |||
{id: 2, content: 'item 2', start: (new Date()).valueOf()}, | |||
{id: '3', content: 'item 3', start: (new Date()).toISOString()}, | |||
{id: 4, content: 'item 4', start: '/Date(1198908717056)/'}, | |||
{id: 5, content: 'item 5', start: undefined}, | |||
{content: 'item 6', start: new Date()} | |||
]); | |||
// named update | |||
dataset.update([ | |||
{id: 1, foo: 'bar'} | |||
], entityId); | |||
google.load("visualization", "1"); | |||
google.setOnLoadCallback(function () { | |||
var table = new google.visualization.DataTable(); | |||
table.addColumn('datetime', 'start'); | |||
table.addColumn('datetime', 'end'); | |||
table.addColumn('string', 'content'); | |||
table.addRows([ | |||
[new Date(2010,7,23), , 'Conversation<br>' + | |||
'<img src="img/comments-icon.png" style="width:32px; height:32px;">'], | |||
[new Date(2010,7,23,23,0,0), , 'Mail from boss<br>' + | |||
'<img src="img/mail-icon.png" style="width:32px; height:32px;">'], | |||
[new Date(2010,7,24,16,0,0), , 'Report'], | |||
[new Date(2010,7,26), new Date(2010,8,2), 'Traject A'], | |||
[new Date(2010,7,28), , 'Memo<br>' + | |||
'<img src="img/notes-edit-icon.png" style="width:48px; height:48px;">'], | |||
[new Date(2010,7,29), , 'Phone call<br>' + | |||
'<img src="img/Hardware-Mobile-Phone-icon.png" style="width:32px; height:32px;">'], | |||
[new Date(2010,7,31), new Date(2010,8,3), 'Traject B'], | |||
[new Date(2010,8,4,12,0,0), , 'Report<br>' + | |||
'<img src="img/attachment-icon.png" style="width:32px; height:32px;">'] | |||
]); | |||
dataset.add(table); | |||
}); | |||
</script> | |||
</body> | |||
</html> |
@ -0,0 +1,87 @@ | |||
<!DOCTYPE HTML> | |||
<html> | |||
<head> | |||
<title></title> | |||
<script src="../lib/moment.min.js"></script> | |||
<script src="../src/util.js"></script> | |||
<script src="../src/events.js"></script> | |||
<script src="../src/timestep.js"></script> | |||
<script src="../src/dataset.js"></script> | |||
<script src="../src/stack.js"></script> | |||
<script src="../src/controller.js"></script> | |||
<script src="../src/range.js"></script> | |||
<script src="../src/component/component.js"></script> | |||
<script src="../src/component/panel.js"></script> | |||
<script src="../src/component/rootpanel.js"></script> | |||
<script src="../src/component/timeaxis.js"></script> | |||
<script src="../src/component/itemset.js"></script> | |||
<script src="../src/component/item/item.js"></script> | |||
<script src="../src/component/item/itembox.js"></script> | |||
<script src="../src/component/item/itemrange.js"></script> | |||
<script src="../src/component/item/itempoint.js"></script> | |||
<script src="../src/visualization/timeline.js"></script> | |||
<link href="../src/component/css/panel.css" rel="stylesheet" type="text/css" /> | |||
<link href="../src/component/css/timeaxis.css" rel="stylesheet" type="text/css" /> | |||
<link href="../src/component/css/item.css" rel="stylesheet" type="text/css" /> | |||
<style> | |||
body, html { | |||
width: 100%; | |||
height: 100%; | |||
padding: 0; | |||
margin: 0; | |||
font-family: arial, sans-serif; | |||
font-size: 12pt; | |||
} | |||
#visualization { | |||
box-sizing: border-box; | |||
padding: 10px; | |||
width: 100%; | |||
height: 300px; | |||
} | |||
#visualization .itemset { | |||
/*background: rgba(255, 255, 0, 0.5);*/ | |||
} | |||
</style> | |||
</head> | |||
<body> | |||
<div id="visualization"></div> | |||
<script> | |||
// create a dataset with items | |||
var now = moment().minutes(0).seconds(0).milliseconds(0); | |||
var data = new DataSet({ | |||
fieldTypes: { | |||
start: 'Date', | |||
end: 'Date' | |||
} | |||
}); | |||
data.add([ | |||
{id: 1, content: 'item 1<br>start', start: now.clone().add('days', 4).toDate()}, | |||
{id: 2, content: 'item 2', start: now.clone().add('days', -2).toDate() }, | |||
{id: 3, content: 'item 3', start: now.clone().add('days', 2).toDate()}, | |||
{id: 4, content: 'item 4', start: now.clone().add('days', 0).toDate(), end: now.clone().add('days', 3).toDate()}, | |||
{id: 5, content: 'item 5', start: now.clone().add('days', 9).toDate(), type:'point'}, | |||
{id: 6, content: 'item 6', start: now.clone().add('days', 11).toDate()} | |||
]); | |||
var container = document.getElementById('visualization'); | |||
var options = { | |||
start: now.clone().add('days', -3).valueOf(), | |||
end: now.clone().add('days', 7).valueOf(), | |||
min: moment('2013-01-01').valueOf(), | |||
max: moment('2013-12-31').valueOf(), | |||
// zoomMin: 1000 * 60 * 60, // 1 hour | |||
zoomMin: 1000 * 60 * 60 * 24, // 1 hour | |||
zoomMax: 1000 * 60 * 60 * 24 * 30 * 6 // 6 months | |||
}; | |||
var timeline = new Timeline(container, data, options); | |||
</script> | |||
</body> | |||
</html> |
@ -0,0 +1,36 @@ | |||
<!DOCTYPE HTML> | |||
<html> | |||
<head> | |||
<title></title> | |||
<script src="../lib/moment.min.js"></script> | |||
<script src="../src/util.js"></script> | |||
<script src="../src/events.js"></script> | |||
<script src="../src/component/panel.js"></script> | |||
<script src="../src/timestep.js"></script> | |||
</head> | |||
<body> | |||
<script> | |||
var diffs = [ | |||
1000, | |||
1000 * 60, | |||
1000 * 60 * 60, | |||
1000 * 60 * 60 * 24, | |||
1000 * 60 * 60 * 24 * 30, | |||
1000 * 60 * 60 * 24 * 30 * 100, | |||
1000 * 60 * 60 * 24 * 30 * 10000 | |||
]; | |||
diffs.forEach(function (diff) { | |||
var step = new TimeStep(new Date(), new Date((new Date()).valueOf() + diff), diff / 40); | |||
console.log(diff, step._start.toLocaleString(), step._end.toLocaleString(), step.scale, step.step); | |||
step.first(); | |||
while (step.hasNext()) { | |||
console.log(step.getLabelMajor(), ' --- ', step.getLabelMinor()); | |||
step.next(); | |||
} | |||
}); | |||
</script> | |||
</body> | |||
</html> |