+* * * * *
+| | | | |
+| | | | +---- Day of the Week (range: 0-6, 1 standing for Monday)
+| | | +------ Month of the Year (range: 1-12)
+| | +-------- Day of the Month (range: 1-31)
+| +---------- Hour (range: 0-23)
++------------ Minute (range: 0-59)
+Example: 0 0 * * * Daily at midnight
+
+ ]]>
+
+ 1
+
+
+
+
+ Allow/disallow to show top running jobs on dashboard.
+ Magento\Config\Model\Config\Source\Yesno
+
+
+
+
+
\ No newline at end of file
diff --git a/etc/config.xml b/etc/config.xml
new file mode 100644
index 0000000..4244766
--- /dev/null
+++ b/etc/config.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+ 0
+ 0
+ 0 10 * * *
+
+
+
+
\ No newline at end of file
diff --git a/etc/crontab.xml b/etc/crontab.xml
new file mode 100644
index 0000000..d424e5c
--- /dev/null
+++ b/etc/crontab.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+ * * * * *
+
+
+
+
+ */15 * * * *
+
+
+ cronscheduler/general/schedule
+
+
+
\ No newline at end of file
diff --git a/etc/di.xml b/etc/di.xml
new file mode 100644
index 0000000..4b34a7f
--- /dev/null
+++ b/etc/di.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/etc/email_templates.xml b/etc/email_templates.xml
new file mode 100644
index 0000000..2babf89
--- /dev/null
+++ b/etc/email_templates.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/etc/module.xml b/etc/module.xml
new file mode 100644
index 0000000..88862ef
--- /dev/null
+++ b/etc/module.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/i18n/en_US.csv b/i18n/en_US.csv
new file mode 100644
index 0000000..7b8e865
--- /dev/null
+++ b/i18n/en_US.csv
@@ -0,0 +1,89 @@
+Back,Back
+Save,Save
+"Execution stopped due to some error.","Execution stopped due to some error."
+"It is killed as running for longer period.","It is killed as running for longer period."
+"The cron job can not be deleted.","The cron job can not be deleted."
+"A total of 1 record(s) have been deleted.","A total of 1 record(s) have been deleted."
+"Edit Cron Job","Edit Cron Job"
+"Cron Scheduler","Cron Scheduler"
+"Cron Jobs","Cron Jobs"
+"Selected jobs can not be scheduled now.","Selected jobs can not be scheduled now."
+"You scheduled selected jobs now.","You scheduled selected jobs now."
+"Selected jobs can not be disabled.","Selected jobs can not be disabled."
+"You disabled selected jobs.","You disabled selected jobs."
+"Selected jobs can not be enabled.","Selected jobs can not be enabled."
+"You enabled selected jobs.","You enabled selected jobs."
+"Add New Cron Job","Add New Cron Job"
+"You saved the cron job.","You saved the cron job."
+"The cron job can not be saved.","The cron job can not be saved."
+"Cron Job Schedule List","Cron Job Schedule List"
+"A total of %1 record(s) have been deleted.","A total of %1 record(s) have been deleted."
+"Selected jobs can not be killed.","Selected jobs can not be killed."
+"It is killed by admin.","It is killed by admin."
+"Cron Scheduler Timeline","Cron Scheduler Timeline"
+CronScheduler,CronScheduler
+"Cron is Working","Cron is Working"
+"Last cron execution is older than %1 hour%2","Last cron execution is older than %1 hour%2"
+"Last cron execution is older than %1 minute%2","Last cron execution is older than %1 minute%2"
+"Last cron execution was %1 minute%2 ago","Last cron execution was %1 minute%2 ago"
+"No cron execution found","No cron execution found"
+Edit,Edit
+Delete,Delete
+"Delete job","Delete job"
+"Are you sure to delete the job?","Are you sure to delete the job?"
+"Top Running Cron Jobs","Top Running Cron Jobs"
+"We couldn't find any records.","We couldn't find any records."
+"Job Code","Job Code"
+"Error Message","Error Message"
+Count,Count
+ENABLE,ENABLE
+DISABLE,DISABLE
+SUCCESS,SUCCESS
+ERROR,ERROR
+MISSED,MISSED
+RUNNING,RUNNING
+KILLED,KILLED
+PENDING,PENDING
+"CronScheduler Job Status","CronScheduler Job Status"
+"Please enter a valid job code.","Please enter a valid job code."
+"Please enter valid class instance.","Please enter valid class instance."
+"Please enter valid class method.","Please enter valid class method."
+"Please enter a valid cron expression.","Please enter a valid cron expression."
+"Please enter a valid email address.","Please enter a valid email address."
+"KiwiCommerce Extensions","KiwiCommerce Extensions"
+General,General
+"Email Configuration","Email Configuration"
+"Schedule Now","Schedule Now"
+"Schedule jobs now","Schedule jobs now"
+"Are you sure to schedule selected jobs now?","Are you sure to schedule selected jobs now?"
+Enable,Enable
+"Enable jobs","Enable jobs"
+"Are you sure to enable selected jobs?","Are you sure to enable selected jobs?"
+Disable,Disable
+"Disable jobs","Disable jobs"
+"Are you sure to disable selected jobs?","Are you sure to disable selected jobs?"
+Group,Group
+Instance,Instance
+Method,Method
+Schedule,Schedule
+Type,Type
+Status,Status
+"Delete jobs","Delete jobs"
+"Are you sure to delete selected jobs?","Are you sure to delete selected jobs?"
+Kill,Kill
+"Kill jobs","Kill jobs"
+"Are you sure to kill selected jobs?","Are you sure to kill selected jobs?"
+ID,ID
+"CPU Usage(ms)","CPU Usage(ms)"
+"System Usage(ms)","System Usage(ms)"
+"Memory Usage(mb)","Memory Usage(mb)"
+Message,Message
+"Created at","Created at"
+"Scheduled at","Scheduled at"
+"Executed at","Executed at"
+"Finished at","Finished at"
+"Cron Job Information","Cron Job Information"
+"Ex: catalog_index_refresh_price.","Ex: catalog_index_refresh_price."
+"Ex : Magento\Catalog\Cron\RefreshSpecialPrices","Ex : Magento\Catalog\Cron\RefreshSpecialPrices"
+"Please enter method name to be executed. Ex: execute.","Please enter method name to be executed. Ex: execute."
+"Add multiple values using comma. Ex: * * * * *,* * * * 2.","Add multiple values using comma. Ex: * * * * *,* * * * 2."
diff --git a/registration.php b/registration.php
new file mode 100644
index 0000000..5645085
--- /dev/null
+++ b/registration.php
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/layout/adminhtml_system_config_edit.xml b/view/adminhtml/layout/adminhtml_system_config_edit.xml
new file mode 100644
index 0000000..a2b81c2
--- /dev/null
+++ b/view/adminhtml/layout/adminhtml_system_config_edit.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/layout/cronscheduler_job_edit.xml b/view/adminhtml/layout/cronscheduler_job_edit.xml
new file mode 100644
index 0000000..397356a
--- /dev/null
+++ b/view/adminhtml/layout/cronscheduler_job_edit.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/layout/cronscheduler_job_listing.xml b/view/adminhtml/layout/cronscheduler_job_listing.xml
new file mode 100644
index 0000000..ecc6322
--- /dev/null
+++ b/view/adminhtml/layout/cronscheduler_job_listing.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/layout/cronscheduler_job_new.xml b/view/adminhtml/layout/cronscheduler_job_new.xml
new file mode 100755
index 0000000..1eb980b
--- /dev/null
+++ b/view/adminhtml/layout/cronscheduler_job_new.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/layout/cronscheduler_schedule_listing.xml b/view/adminhtml/layout/cronscheduler_schedule_listing.xml
new file mode 100644
index 0000000..45083a6
--- /dev/null
+++ b/view/adminhtml/layout/cronscheduler_schedule_listing.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/layout/cronscheduler_schedule_timeline.xml b/view/adminhtml/layout/cronscheduler_schedule_timeline.xml
new file mode 100644
index 0000000..20f09ba
--- /dev/null
+++ b/view/adminhtml/layout/cronscheduler_schedule_timeline.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/templates/dashboard/dashboardjobs.phtml b/view/adminhtml/templates/dashboard/dashboardjobs.phtml
new file mode 100644
index 0000000..27ebd60
--- /dev/null
+++ b/view/adminhtml/templates/dashboard/dashboardjobs.phtml
@@ -0,0 +1,50 @@
+isDashboardActive())
+{
+ $cronjobs = $block->getTopRunningJobs();
+?>
+
+
= /* @escapeNotVerified */ __("We couldn't find any records.") ?>
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/templates/default/seturl.phtml b/view/adminhtml/templates/default/seturl.phtml
new file mode 100644
index 0000000..33b4914
--- /dev/null
+++ b/view/adminhtml/templates/default/seturl.phtml
@@ -0,0 +1,21 @@
+
+
\ No newline at end of file
diff --git a/view/adminhtml/templates/timeline/timeline.phtml b/view/adminhtml/templates/timeline/timeline.phtml
new file mode 100644
index 0000000..ab4f1ca
--- /dev/null
+++ b/view/adminhtml/templates/timeline/timeline.phtml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/ui_component/cronscheduler_job_listing.xml b/view/adminhtml/ui_component/cronscheduler_job_listing.xml
new file mode 100644
index 0000000..bac3f5c
--- /dev/null
+++ b/view/adminhtml/ui_component/cronscheduler_job_listing.xml
@@ -0,0 +1,245 @@
+
+
+
+
+
+ cronscheduler_job_listing.job_listing_data_source
+ cronscheduler_job_listing.job_listing_data_source
+
+ job_columns
+
+
+ add
+ Add New Cron Job
+ primary
+ */*/new
+
+
+
+
+
+ KiwiCommerce\CronScheduler\Ui\DataProvider\JobProvider
+ job_listing_data_source
+ code
+ code
+
+
+
+
+ code
+
+
+
+
+
+
+ Magento_Ui/js/grid/provider
+
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+ cronscheduler_job_listing.cronscheduler_job_listing.listing_top.bookmarks
+ current.filters
+
+
+ cronscheduler_job_listing.cronscheduler_job_listing.listing_top.listing_filters
+
+ cronscheduler_job_listing.cronscheduler_job_listing.listing_top.bookmarks:current.columns.${ $.index }.visible
+
+
+
+
+
+
+
+
+
+ cronscheduler_job_listing.cronscheduler_job_listing.listing_top.bookmarks
+ current.paging
+
+ bottom
+
+
+
+
+
+
+ cronscheduler_job_listing.cronscheduler_job_listing.job_columns.jobcodes
+ code
+
+
+
+
+
+ schedule_now
+ Schedule Now
+
+
+ Schedule jobs now
+ Are you sure to schedule selected jobs now?
+
+
+
+
+
+
+
+ status_enable
+ Enable
+
+
+ Enable jobs
+ Are you sure to enable selected jobs?
+
+
+
+
+
+
+
+ status_disable
+ Disable
+
+
+ Disable jobs
+ Are you sure to disable selected jobs?
+
+
+
+
+
+
+
+
+
+
+
+ cronscheduler_job_listing.cronscheduler_job_listing.listing_top.bookmarks
+ current
+
+
+ true
+
+ cronscheduler_job_listing.cronscheduler_job_listing.listing_top.bookmarks
+ columns.${ $.index }
+ current.${ $.storageConfig.root}
+
+
+
+
+
+
+
+ code
+ 10
+ true
+
+
+
+
+
+
+ text
+ Job Code
+ 20
+ true
+
+
+
+
+
+
+ text
+ Group
+ 30
+ true
+
+
+
+
+
+
+ text
+ Instance
+ 40
+ true
+
+
+
+
+
+
+ text
+ Method
+ 45
+ true
+
+
+
+
+
+
+ text
+ Schedule
+ 50
+ true
+
+
+
+
+
+
+ text
+ Type
+ 60
+ true
+
+
+
+
+
+ KiwiCommerce\CronScheduler\Ui\DataProvider\Form\Options
+
+ select
+ select
+ Status
+ KiwiCommerce_CronScheduler/job/status
+ 70
+ true
+
+
+
+
+
+
+ schedule_id
+
+
+
+
+
+
diff --git a/view/adminhtml/ui_component/cronscheduler_schedule_listing.xml b/view/adminhtml/ui_component/cronscheduler_schedule_listing.xml
new file mode 100644
index 0000000..8f7028e
--- /dev/null
+++ b/view/adminhtml/ui_component/cronscheduler_schedule_listing.xml
@@ -0,0 +1,261 @@
+
+
+
+
+
+ cronscheduler_schedule_listing.schedule_listing_data_source
+ cronscheduler_schedule_listing.schedule_listing_data_source
+
+ schedule_columns
+
+
+
+ KiwiCommerce\CronScheduler\Ui\DataProvider\ScheduleProvider
+ schedule_listing_data_source
+ schedule_id
+ schedule_id
+
+
+
+
+ schedule_id
+
+
+
+
+
+
+ Magento_Ui/js/grid/provider
+
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+ cronscheduler_schedule_listing.cronscheduler_schedule_listing.listing_top.bookmarks
+ current.filters
+
+
+ cronscheduler_schedule_listing.cronscheduler_schedule_listing.listing_top.listing_filters
+
+ cronscheduler_schedule_listing.cronscheduler_schedule_listing.listing_top.bookmarks:current.columns.${ $.index }.visible
+
+
+
+
+
+
+
+
+
+ cronscheduler_schedule_listing.cronscheduler_schedule_listing.listing_top.bookmarks
+ current.paging
+
+ cronscheduler_schedule_listing.cronscheduler_schedule_listing.schedule_columns.schedule_id
+ bottom
+
+
+
+
+
+
+ cronscheduler_schedule_listing.cronscheduler_schedule_listing.schedule_columns.schedule_ids
+ schedule_id
+
+
+
+
+
+ delete
+ Delete
+
+
+ Delete jobs
+ Are you sure to delete selected jobs?
+
+
+
+
+
+
+
+ kill
+ Kill
+
+
+ Kill jobs
+ Are you sure to kill selected jobs?
+
+
+
+
+
+
+
+
+
+
+ cronscheduler_schedule_listing.cronscheduler_schedule_listing.listing_top.bookmarks
+ current
+
+
+ true
+
+ cronscheduler_schedule_listing.cronscheduler_schedule_listing.listing_top.bookmarks
+ columns.${ $.index }
+ current.${ $.storageConfig.root}
+
+
+
+
+
+
+
+ schedule_id
+ 1
+ true
+
+
+
+
+
+
+ ID
+ 10
+ true
+
+
+
+
+
+
+ text
+ Job Code
+ 20
+ true
+
+
+
+
+
+
+ text
+ Status
+ KiwiCommerce_CronScheduler/schedule/status
+ 30
+ true
+
+
+
+
+
+
+ textRange
+ CPU Usage(ms)
+ 40
+ true
+
+
+
+
+
+
+ textRange
+ System Usage(ms)
+ 50
+ true
+
+
+
+
+
+
+ textRange
+ Memory Usage(mb)
+ 60
+ true
+
+
+
+
+
+
+ text
+ Message
+ 70
+ true
+
+
+
+
+
+
+ dateRange
+ Magento_Ui/js/grid/columns/date
+ date
+ Created at
+ 80
+ true
+
+
+
+
+
+
+ dateRange
+ Magento_Ui/js/grid/columns/date
+ date
+ Scheduled at
+ 90
+ true
+
+
+
+
+
+
+ dateRange
+ Magento_Ui/js/grid/columns/date
+ date
+ Executed at
+ 100
+ true
+
+
+
+
+
+
+ dateRange
+ Magento_Ui/js/grid/columns/date
+ date
+ Finished at
+ 110
+ true
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/ui_component/cronschedulerjob_edit_form.xml b/view/adminhtml/ui_component/cronschedulerjob_edit_form.xml
new file mode 100644
index 0000000..920ced5
--- /dev/null
+++ b/view/adminhtml/ui_component/cronschedulerjob_edit_form.xml
@@ -0,0 +1,200 @@
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/ui_component/cronschedulerjob_form.xml b/view/adminhtml/ui_component/cronschedulerjob_form.xml
new file mode 100644
index 0000000..ae96b86
--- /dev/null
+++ b/view/adminhtml/ui_component/cronschedulerjob_form.xml
@@ -0,0 +1,186 @@
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/web/css/kiwicommerce.css b/view/adminhtml/web/css/kiwicommerce.css
new file mode 100644
index 0000000..dde2834
--- /dev/null
+++ b/view/adminhtml/web/css/kiwicommerce.css
@@ -0,0 +1,24 @@
+/**
+* KiwiCommerce
+*
+* Do not edit or add to this file if you wish to upgrade to newer versions in the future.
+* If you wish to customise this module for your needs.
+* Please contact us https://kiwicommerce.co.uk/contacts.
+*
+* @category KiwiCommerce
+* @package KiwiCommerce_CronScheduler
+* @copyright Copyright (C) 2018 Kiwi Commerce Ltd (https://kiwicommerce.co.uk/)
+* @license https://kiwicommerce.co.uk/magento2-extension-license/
+*/
+
+span.grid-severity-running{
+ background: #ACC4F7;
+ border: 1px solid #125BF6;
+ color: #125BF6;
+ display: block;
+ font-weight: bold;
+ line-height: 17px;
+ padding: 0 3px;
+ text-align: center;
+ text-transform: uppercase;
+}
\ No newline at end of file
diff --git a/view/adminhtml/web/css/timeline.css b/view/adminhtml/web/css/timeline.css
new file mode 100644
index 0000000..f19e013
--- /dev/null
+++ b/view/adminhtml/web/css/timeline.css
@@ -0,0 +1,237 @@
+/**
+* KiwiCommerce
+*
+* Do not edit or add to this file if you wish to upgrade to newer versions in the future.
+* If you wish to customise this module for your needs.
+* Please contact us https://kiwicommerce.co.uk/contacts.
+*
+* @category KiwiCommerce
+* @package KiwiCommerce_CronScheduler
+* @copyright Copyright (C) 2018 Kiwi Commerce Ltd (https://kiwicommerce.co.uk/)
+* @license https://kiwicommerce.co.uk/magento2-extension-license/
+*/
+
+div.timeline-frame {
+ -moz-box-sizing: border-box;
+ border: 1px solid #bebebe;
+ box-sizing: border-box;
+ overflow: hidden;
+ position: relative;
+}
+
+div.timeline-content {
+ overflow: hidden;
+ position: relative;
+}
+
+div.timeline-axis {
+ -moz-box-sizing: border-box;
+ border-color: #bebebe;
+ border-top-style: solid;
+ border-width: 1px;
+ box-sizing: border-box;
+}
+
+div.timeline-axis-grid {
+ -moz-box-sizing: border-box;
+ border-left-style: solid;
+ border-width: 1px;
+ box-sizing: border-box;
+}
+
+div.timeline-axis-grid-minor {
+ border-color: #e5e5e5;
+}
+
+div.timeline-axis-grid-major {
+ border-color: #bfbfbf;
+}
+
+div.timeline-axis-text {
+ color: #4d4d4d;
+ padding: 3px;
+ white-space: nowrap;
+}
+
+div.timeline-axis-text-minor {
+}
+
+div.timeline-axis-text-major {
+}
+
+div.timeline-event {
+ -moz-box-sizing: border-box;
+ background-color: #d5ddf6;
+ border-color: #97b0f8;
+ box-sizing: border-box;
+ color: #1a1a1a;
+ display: inline-block;
+}
+
+div.timeline-event-selected {
+ background-color: #fff785;
+ border-color: #ffc200;
+ z-index: 999;
+}
+
+/* TODO: use another color or pattern? */
+div.timeline-event-cluster {
+ color: #ffffff;
+}
+
+div.timeline-event-cluster div.timeline-event-dot {
+ border-color: #d5ddf6;
+}
+
+div.timeline-event-box {
+ -moz-border-radius: 5px;
+ border-radius: 5px;
+ border-style: solid;
+ border-width: 1px;
+ text-align: center;
+}
+
+div.timeline-event-dot {
+ -moz-border-radius: 5px;
+ border-radius: 5px;
+ border-style: solid;
+ border-width: 5px;
+}
+
+div.timeline-event-range {
+ -moz-border-radius: 2px;
+ border-radius: 2px;
+ border-style: solid;
+ border-width: 1px;
+}
+
+div.timeline-event-range-drag-left {
+ cursor: w-resize;
+ z-index: 1000;
+}
+
+div.timeline-event-range-drag-right {
+ cursor: e-resize;
+ z-index: 1000;
+}
+
+div.timeline-event-line {
+ -moz-box-sizing: border-box;
+ border-left-style: solid;
+ border-left-width: 1px;
+ box-sizing: border-box;
+}
+
+div.timeline-event-content {
+ margin: 5px;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+div.timeline-groups-axis {
+ -moz-box-sizing: border-box;
+ border-color: #bebebe;
+ border-width: 1px;
+ box-sizing: border-box;
+}
+
+div.timeline-groups-axis-onleft {
+ border-style: none solid none none;
+}
+
+div.timeline-groups-axis-onright {
+ border-style: none none none solid;
+}
+
+div.timeline-groups-text {
+ color: #4d4d4d;
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
+div.timeline-currenttime {
+ -moz-box-sizing: border-box;
+ background-color: #ff7f6e;
+ box-sizing: border-box;
+ width: 2px;
+}
+
+div.timeline-customtime {
+ -moz-box-sizing: border-box;
+ background-color: #6e94ff;
+ box-sizing: border-box;
+ cursor: move;
+ width: 2px;
+}
+
+div.timeline-navigation {
+ -moz-border-radius: 2px;
+ -moz-box-sizing: border-box;
+ background-color: #f5f5f5;
+ border: 1px solid #bebebe;
+ border-radius: 2px;
+ box-sizing: border-box;
+ color: #808080;
+ font-family: arial;
+ font-size: 20px;
+ font-weight: bold;
+}
+
+div.timeline-navigation-new,
+div.timeline-navigation-delete,
+div.timeline-navigation-zoom-in,
+div.timeline-navigation-zoom-out,
+div.timeline-navigation-move-left,
+div.timeline-navigation-move-right {
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ cursor: pointer;
+ float: left;
+ height: 36px;
+ padding: 10px;
+ text-decoration: none;
+ width: 36px;
+}
+
+div.timeline-event {
+ min-width: 1px;
+ padding: 0px;
+ height: 36px;
+}
+
+div.timeline-event.success {
+ background:green;
+ border-color:green;
+}
+div.timeline-event.missed,
+div.timeline-event.error {
+ background:red;
+ border-color:red;
+}
+div.timeline-event.running {
+ background: #ffd400;
+ border-color: #ffd400;
+}
+
+div.timeline-event.pending {
+ background:#ea992e;
+ border-color:#ea992e;
+}
+.timeline-content {
+ border: 0px solid transparent !important;
+}
+
+#kiwicommercetimeline-tooltip {
+ background:white;
+ border: 1px solid black;
+ padding:5px;
+}
+#kiwicommercetimeline-tooltip table td {
+ padding:3px;
+ top: -5px;
+ left: 105%;
+}
+
+#kiwicommercetimeline-tooltip table tr td:first-child {
+ font-weight: bold;
+}
\ No newline at end of file
diff --git a/view/adminhtml/web/js/kiwicommerce_validation.js b/view/adminhtml/web/js/kiwicommerce_validation.js
new file mode 100644
index 0000000..fd77e8e
--- /dev/null
+++ b/view/adminhtml/web/js/kiwicommerce_validation.js
@@ -0,0 +1,136 @@
+/**
+ * KiwiCommerce
+ *
+ * Do not edit or add to this file if you wish to upgrade to newer versions in the future.
+ * If you wish to customise this module for your needs.
+ * Please contact us https://kiwicommerce.co.uk/contacts.
+ *
+ * @category KiwiCommerce
+ * @package KiwiCommerce_CronScheduler
+ * @copyright Copyright (C) 2018 Kiwi Commerce Ltd (https://kiwicommerce.co.uk/)
+ * @license https://kiwicommerce.co.uk/magento2-extension-license/
+ */
+
+require(
+ [
+ 'Magento_Ui/js/lib/validation/validator',
+ 'jquery',
+ 'mage/url',
+ 'mage/translate'
+ ],
+ function (validator, $, urlBuilder) {
+
+ validator.addRule(
+ 'uniquejobcode',
+ function (value) {
+ var result = false;
+ var linkUrl = urlBuilder.build("cronscheduler/validation/uniquejobcode");
+
+ $.ajax({
+ type:"POST",
+ async: false,
+ url: linkUrl, // script to validate in server side
+ data: {jobcode: value},
+ success: function (data) {
+ result = (data.success == true) ? false : true;
+ }
+ });
+
+ return result;
+ }
+ ,
+ $.mage.__('Please enter a valid job code.')
+ );
+
+ validator.addRule(
+ 'classexistance',
+ function (value) {
+ var result = false;
+ var linkUrl = urlBuilder.build("cronscheduler/validation/classexistance");
+
+ $.ajax({
+ type:"POST",
+ async: false,
+ url: linkUrl, // script to validate in server side
+ data: {classpath: value},
+ success: function (data) {
+ result = (data.success == true) ? true : false;
+ }
+ });
+
+ return result;
+ }
+ ,
+ $.mage.__('Please enter valid class instance.')
+ );
+
+ validator.addRule(
+ 'methodexistance',
+ function (value) {
+
+ var result = false;
+ var linkUrl = urlBuilder.build("cronscheduler/validation/methodexistance");
+ var classpath = $("input[name=instance]").val();
+
+ $.ajax({
+ type:"POST",
+ async: false,
+ url: linkUrl, // script to validate in server side
+ data: {classpath: classpath,methodname: value},
+ success: function (data) {
+ result = (data.success == true) ? true : false;
+ }
+ });
+
+ return result;
+ }
+ ,
+ $.mage.__('Please enter valid class method.')
+ );
+
+ validator.addRule(
+ 'checkexpression',
+ function (value) {
+
+ var result = false;
+ var linkUrl = urlBuilder.build("cronscheduler/validation/cronexpression");
+
+ $.ajax({
+ type:"POST",
+ async: false,
+ url: linkUrl, // script to validate in server side
+ data: {expression: value},
+ success: function (data) {
+ result = (data.success == true) ? true : false;
+ }
+ });
+
+ return result;
+ }
+ ,
+ $.mage.__('Please enter a valid cron expression.')
+ );
+
+ $.validator.addMethod(
+ 'validate-comma-separated-emails',
+ function (emaillist) {
+ emaillist = emaillist.trim();
+ if (emaillist.charAt(0) == ',' || emaillist.charAt(emaillist.length - 1) == ',') {
+ return false; }
+ var emails = emaillist.split(',');
+ var invalidEmails = [];
+ for (i = 0; i < emails.length; i++) {
+ var v = emails[i].trim();
+ if (!Validation.get('validate-email').test(v)) {
+ invalidEmails.push(v);
+ }
+ }
+ if (invalidEmails.length) {
+ return false; }
+ return true;
+
+ },
+ $.mage.__('Please enter a valid email address.')
+ );
+ }
+);
\ No newline at end of file
diff --git a/view/adminhtml/web/js/timeline.js b/view/adminhtml/web/js/timeline.js
new file mode 100644
index 0000000..1e1c9ba
--- /dev/null
+++ b/view/adminhtml/web/js/timeline.js
@@ -0,0 +1,6981 @@
+/**
+ * @file timeline.js
+ *
+ * @brief
+ * The Timeline is an interactive visualization chart to visualize events in
+ * time, having a start and end date.
+ * You can freely move and zoom in the timeline by dragging
+ * and scrolling in the Timeline. Items are optionally dragable. The time
+ * scale on the axis is adjusted automatically, and supports scales ranging
+ * from milliseconds to years.
+ *
+ * Timeline is part of the CHAP Links library.
+ *
+ * Timeline is tested on Firefox 3.6, Safari 5.0, Chrome 6.0, Opera 10.6, and
+ * Internet Explorer 6+.
+ *
+ * @license
+ * 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.
+ *
+ * Copyright (c) 2011-2015 Almende B.V.
+ *
+ * @author Jos de Jong,
+ * @date 2015-03-04
+ * @version 2.9.1
+ */
+
+/*
+ * i18n mods by github user iktuz (https://gist.github.com/iktuz/3749287/)
+ * added to v2.4.1 with da_DK language by @bjarkebech
+ */
+
+/*
+ * TODO
+ *
+ * Add zooming with pinching on Android
+ *
+ * Bug: when an item contains a javascript onclick or a link, this does not work
+ * when the item is not selected (when the item is being selected,
+ * it is redrawn, which cancels any onclick or link action)
+ * Bug: when an item contains an image without size, or a css max-width, it is not sized correctly
+ * Bug: neglect items when they have no valid start/end, instead of throwing an error
+ * Bug: Pinching on ipad does not work very well, sometimes the page will zoom when pinching vertically
+ * Bug: cannot set max width for an item, like div.timeline-event-content {white-space: normal; max-width: 100px;}
+ * Bug on IE in Quirks mode. When you have groups, and delete an item, the groups become invisible
+ */
+
+/**
+ * Declare a unique namespace for CHAP's Common Hybrid Visualisation Library,
+ * "links"
+ */
+if (typeof links === 'undefined') {
+ links = {};
+ // important: do not use var, as "var links = {};" will overwrite
+ // the existing links variable value with undefined in IE8, IE7.
+}
+
+
+/**
+ * Ensure the variable google exists
+ */
+if (typeof google === 'undefined') {
+ google = undefined;
+ // important: do not use var, as "var google = undefined;" will overwrite
+ // the existing google variable value with undefined in IE8, IE7.
+}
+
+
+
+// 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;
+ }
+}
+
+// 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);
+ }
+ }
+}
+
+
+/**
+ * @constructor links.Timeline
+ * The timeline is a visualization chart to visualize events in time.
+ *
+ * The timeline is developed in javascript as a Google Visualization Chart.
+ *
+ * @param {Element} container The DOM element in which the Timeline will
+ * be created. Normally a div element.
+ * @param {Object} options A name/value map containing settings for the
+ * timeline. Optional.
+ */
+links.Timeline = function (container, options) {
+ if (!container) {
+ // this call was probably only for inheritance, no constructor-code is required
+ return;
+ }
+
+ // create variables and set default values
+ this.dom = {};
+ this.conversion = {};
+ this.eventParams = {}; // stores parameters for mouse events
+ this.groups = [];
+ this.groupIndexes = {};
+ this.items = [];
+ this.renderQueue = {
+ show: [], // Items made visible but not yet added to DOM
+ hide: [], // Items currently visible but not yet removed from DOM
+ update: [] // Items with changed data but not yet adjusted DOM
+ };
+ this.renderedItems = []; // Items currently rendered in the DOM
+ this.clusterGenerator = new links.Timeline.ClusterGenerator(this);
+ this.currentClusters = [];
+ this.selection = undefined; // stores index and item which is currently selected
+
+ this.listeners = {}; // event listener callbacks
+
+ // Initialize sizes.
+ // Needed for IE (which gives an error when you try to set an undefined
+ // value in a style)
+ this.size = {
+ 'actualHeight': 0,
+ 'axis': {
+ 'characterMajorHeight': 0,
+ 'characterMajorWidth': 0,
+ 'characterMinorHeight': 0,
+ 'characterMinorWidth': 0,
+ 'height': 0,
+ 'labelMajorTop': 0,
+ 'labelMinorTop': 0,
+ 'line': 0,
+ 'lineMajorWidth': 0,
+ 'lineMinorHeight': 0,
+ 'lineMinorTop': 0,
+ 'lineMinorWidth': 0,
+ 'top': 0
+ },
+ 'contentHeight': 0,
+ 'contentLeft': 0,
+ 'contentWidth': 0,
+ 'frameHeight': 0,
+ 'frameWidth': 0,
+ 'groupsLeft': 0,
+ 'groupsWidth': 0,
+ 'items': {
+ 'top': 0
+ }
+ };
+
+ this.dom.container = container;
+
+ //
+ // Let's set the default options first
+ //
+ this.options = {
+ 'width': "100%",
+ 'height': "auto",
+ 'minHeight': 0, // minimal height in pixels
+ 'groupMinHeight': 0,
+ 'autoHeight': true,
+
+ 'eventMargin': 10, // minimal margin between events
+ 'eventMarginAxis': 20, // minimal margin between events and the axis
+ 'dragAreaWidth': 10, // pixels
+
+ 'min': undefined,
+ 'max': undefined,
+ 'zoomMin': 10, // milliseconds
+ 'zoomMax': 1000 * 60 * 60 * 24 * 365 * 10000, // milliseconds
+
+ 'moveable': true,
+ 'zoomable': true,
+ 'selectable': true,
+ 'unselectable': true,
+ 'editable': false,
+ 'snapEvents': true,
+ 'groupsChangeable': true,
+ 'timeChangeable': true,
+
+ 'showCurrentTime': true, // show a red bar displaying the current time
+ 'showCustomTime': false, // show a blue, draggable bar displaying a custom time
+ 'showMajorLabels': true,
+ 'showMinorLabels': true,
+ 'showNavigation': false,
+ 'showButtonNew': false,
+ 'groupsOnRight': false,
+ 'groupsOrder' : true,
+ 'axisOnTop': false,
+ 'stackEvents': true,
+ 'animate': true,
+ 'animateZoom': true,
+ 'cluster': false,
+ 'clusterMaxItems': 5,
+ 'style': 'box',
+ 'customStackOrder': false, //a function(a,b) for determining stackorder amongst a group of items. Essentially a comparator, -ve value for "a before b" and vice versa
+
+ // i18n: Timeline only has built-in English text per default. Include timeline-locales.js to support more localized text.
+ 'locale': 'en',
+ 'MONTHS': ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
+ 'MONTHS_SHORT': ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
+ 'DAYS': ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
+ 'DAYS_SHORT': ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
+ 'ZOOM_IN': "Zoom in",
+ 'ZOOM_OUT': "Zoom out",
+ 'MOVE_LEFT': "Move left",
+ 'MOVE_RIGHT': "Move right",
+ 'NEW': "New",
+ 'CREATE_NEW_EVENT': "Create new event"
+ };
+
+ //
+ // Now we can set the givenproperties
+ //
+ this.setOptions(options);
+
+ this.clientTimeOffset = 0; // difference between client time and the time
+ // set via Timeline.setCurrentTime()
+ var dom = this.dom;
+
+ // remove all elements from the container element.
+ while (dom.container.hasChildNodes()) {
+ dom.container.removeChild(dom.container.firstChild);
+ }
+
+ // create a step for drawing the axis
+ this.step = new links.Timeline.StepDate();
+
+ // add standard item types
+ this.itemTypes = {
+ box: links.Timeline.ItemBox,
+ range: links.Timeline.ItemRange,
+ floatingRange: links.Timeline.ItemFloatingRange,
+ dot: links.Timeline.ItemDot
+ };
+
+ // initialize data
+ this.data = [];
+ this.firstDraw = true;
+
+ // date interval must be initialized
+ this.setVisibleChartRange(undefined, undefined, false);
+
+ // render for the first time
+ this.render();
+
+ // fire the ready event
+ var me = this;
+ setTimeout(function () {
+ me.trigger('ready');
+ }, 0);
+};
+
+
+/**
+ * Main drawing logic. This is the function that needs to be called
+ * in the html page, to draw the timeline.
+ *
+ * A data table with the events must be provided, and an options table.
+ *
+ * @param {google.visualization.DataTable} data
+ * The data containing the events for the timeline.
+ * Object DataTable is defined in
+ * google.visualization.DataTable
+ * @param {Object} options A name/value map containing settings for the
+ * timeline. Optional. The use of options here
+ * is deprecated. Pass timeline options in the
+ * constructor or use setOptions()
+ */
+links.Timeline.prototype.draw = function (data, options) {
+ if (options) {
+ console.log("WARNING: Passing options in draw() is deprecated. Pass options to the constructur or use setOptions() instead!");
+ this.setOptions(options);
+ }
+
+ if (this.options.selectable) {
+ links.Timeline.addClassName(this.dom.frame, "timeline-selectable");
+ }
+
+ // read the data
+ this.setData(data);
+
+ if (this.firstDraw) {
+ this.setVisibleChartRangeAuto();
+ }
+
+ this.firstDraw = false;
+};
+
+
+/**
+ * Set options for the timeline.
+ * Timeline must be redrawn afterwards
+ * @param {Object} options A name/value map containing settings for the
+ * timeline. Optional.
+ */
+links.Timeline.prototype.setOptions = function (options) {
+ if (options) {
+ // retrieve parameter values
+ for (var i in options) {
+ if (options.hasOwnProperty(i)) {
+ this.options[i] = options[i];
+ }
+ }
+
+ // prepare i18n dependent on set locale
+ if (typeof links.locales !== 'undefined' && this.options.locale !== 'en') {
+ var localeOpts = links.locales[this.options.locale];
+ if (localeOpts) {
+ for (var l in localeOpts) {
+ if (localeOpts.hasOwnProperty(l)) {
+ this.options[l] = localeOpts[l];
+ }
+ }
+ }
+ }
+
+ // check for deprecated options
+ if (options.showButtonAdd != undefined) {
+ this.options.showButtonNew = options.showButtonAdd;
+ console.log('WARNING: Option showButtonAdd is deprecated. Use showButtonNew instead');
+ }
+ if (options.intervalMin != undefined) {
+ this.options.zoomMin = options.intervalMin;
+ console.log('WARNING: Option intervalMin is deprecated. Use zoomMin instead');
+ }
+ if (options.intervalMax != undefined) {
+ this.options.zoomMax = options.intervalMax;
+ console.log('WARNING: Option intervalMax is deprecated. Use zoomMax instead');
+ }
+
+ if (options.scale && options.step) {
+ this.step.setScale(options.scale, options.step);
+ }
+ }
+
+ // validate options
+ this.options.autoHeight = (this.options.height === "auto");
+};
+
+/**
+ * Get options for the timeline.
+ *
+ * @return the options object
+ */
+links.Timeline.prototype.getOptions = function () {
+ return this.options;
+};
+
+/**
+ * Add new type of items
+ * @param {String} typeName Name of new type
+ * @param {links.Timeline.Item} typeFactory Constructor of items
+ */
+links.Timeline.prototype.addItemType = function (typeName, typeFactory) {
+ this.itemTypes[typeName] = typeFactory;
+};
+
+/**
+ * Retrieve a map with the column indexes of the columns by column name.
+ * For example, the method returns the map
+ * {
+ * start: 0,
+ * end: 1,
+ * content: 2,
+ * group: undefined,
+ * className: undefined
+ * editable: undefined
+ * type: undefined
+ * }
+ * @param {google.visualization.DataTable} dataTable
+ * @type {Object} map
+ */
+links.Timeline.mapColumnIds = function (dataTable) {
+ var cols = {},
+ colCount = dataTable.getNumberOfColumns(),
+ allUndefined = true;
+
+ // loop over the columns, and map the column id's to the column indexes
+ for (var col = 0; col < colCount; col++) {
+ var id = dataTable.getColumnId(col) || dataTable.getColumnLabel(col);
+ cols[id] = col;
+ if (id == 'start' || id == 'end' || id == 'content' || id == 'group' ||
+ id == 'className' || id == 'editable' || id == 'type') {
+ allUndefined = false;
+ }
+ }
+
+ // if no labels or ids are defined, use the default mapping
+ // for start, end, content, group, className, editable, type
+ if (allUndefined) {
+ cols.start = 0;
+ cols.end = 1;
+ cols.content = 2;
+ if (colCount > 3) {
+cols.group = 3}
+ if (colCount > 4) {
+cols.className = 4}
+ if (colCount > 5) {
+cols.editable = 5}
+ if (colCount > 6) {
+cols.type = 6}
+ }
+
+ return cols;
+};
+
+/**
+ * Set data for the timeline
+ * @param {google.visualization.DataTable | Array} data
+ */
+links.Timeline.prototype.setData = function (data) {
+ // unselect any previously selected item
+ this.unselectItem();
+
+ if (!data) {
+ data = [];
+ }
+
+ // clear all data
+ this.stackCancelAnimation();
+ this.clearItems();
+ this.data = data;
+ var items = this.items;
+ this.deleteGroups();
+
+ if (google && google.visualization &&
+ data instanceof google.visualization.DataTable) {
+ // map the datatable columns
+ var cols = links.Timeline.mapColumnIds(data);
+
+ // read DataTable
+ for (var row = 0, rows = data.getNumberOfRows(); row < rows; row++) {
+ items.push(this.createItem({
+ 'start': ((cols.start != undefined) ? data.getValue(row, cols.start) : undefined),
+ 'end': ((cols.end != undefined) ? data.getValue(row, cols.end) : undefined),
+ 'content': ((cols.content != undefined) ? data.getValue(row, cols.content) : undefined),
+ 'group': ((cols.group != undefined) ? data.getValue(row, cols.group) : undefined),
+ 'className': ((cols.className != undefined) ? data.getValue(row, cols.className) : undefined),
+ 'editable': ((cols.editable != undefined) ? data.getValue(row, cols.editable) : undefined),
+ 'type': ((cols.type != undefined) ? data.getValue(row, cols.type) : undefined)
+ }));
+ }
+ } else if (links.Timeline.isArray(data)) {
+ // read JSON array
+ for (var row = 0, rows = data.length; row < rows; row++) {
+ var itemData = data[row];
+ var item = this.createItem(itemData);
+ items.push(item);
+ }
+ } else {
+ throw "Unknown data type. DataTable or Array expected.";
+ }
+
+ // prepare data for clustering, by filtering and sorting by type
+ if (this.options.cluster) {
+ this.clusterGenerator.setData(this.items);
+ }
+
+ this.render({
+ animate: false
+ });
+};
+
+/**
+ * Return the original data table.
+ * @return {google.visualization.DataTable | Array} data
+ */
+links.Timeline.prototype.getData = function () {
+ return this.data;
+};
+
+
+/**
+ * Update the original data with changed start, end or group.
+ *
+ * @param {Number} index
+ * @param {Object} values An object containing some of the following parameters:
+ * {Date} start,
+ * {Date} end,
+ * {String} content,
+ * {String} group
+ */
+links.Timeline.prototype.updateData = function (index, values) {
+ var data = this.data,
+ prop;
+
+ if (google && google.visualization &&
+ data instanceof google.visualization.DataTable) {
+ // update the original google DataTable
+ var missingRows = (index + 1) - data.getNumberOfRows();
+ if (missingRows > 0) {
+ data.addRows(missingRows);
+ }
+
+ // map the column id's by name
+ var cols = links.Timeline.mapColumnIds(data);
+
+ // merge all fields from the provided data into the current data
+ for (prop in values) {
+ if (values.hasOwnProperty(prop)) {
+ var col = cols[prop];
+ if (col == undefined) {
+ // create new column
+ var value = values[prop];
+ var valueType = 'string';
+ if (typeof(value) == 'number') {
+valueType = 'number';} else if (typeof(value) == 'boolean') {
+valueType = 'boolean';} else if (value instanceof Date) {
+valueType = 'datetime';}
+ col = data.addColumn(valueType, prop);
+ }
+ data.setValue(index, col, values[prop]);
+
+ // TODO: correctly serialize the start and end Date to the desired type (Date, String, or Number)
+ }
+ }
+ } else if (links.Timeline.isArray(data)) {
+ // update the original JSON table
+ var row = data[index];
+ if (row == undefined) {
+ row = {};
+ data[index] = row;
+ }
+
+ // merge all fields from the provided data into the current data
+ for (prop in values) {
+ if (values.hasOwnProperty(prop)) {
+ row[prop] = values[prop];
+
+ // TODO: correctly serialize the start and end Date to the desired type (Date, String, or Number)
+ }
+ }
+ } else {
+ throw "Cannot update data, unknown type of data";
+ }
+};
+
+/**
+ * Find the item index from a given HTML element
+ * If no item index is found, undefined is returned
+ * @param {Element} element
+ * @return {Number | undefined} index
+ */
+links.Timeline.prototype.getItemIndex = function (element) {
+ var e = element,
+ dom = this.dom,
+ frame = dom.items.frame,
+ items = this.items,
+ index = undefined;
+
+ // try to find the frame where the items are located in
+ while (e.parentNode && e.parentNode !== frame) {
+ e = e.parentNode;
+ }
+
+ if (e.parentNode === frame) {
+ // yes! we have found the parent element of all items
+ // retrieve its id from the array with items
+ for (var i = 0, iMax = items.length; i < iMax; i++) {
+ if (items[i].dom === e) {
+ index = i;
+ break;
+ }
+ }
+ }
+
+ return index;
+};
+
+
+/**
+ * Find the cluster index from a given HTML element
+ * If no cluster index is found, undefined is returned
+ * @param {Element} element
+ * @return {Number | undefined} index
+ */
+links.Timeline.prototype.getClusterIndex = function (element) {
+ var e = element,
+ dom = this.dom,
+ frame = dom.items.frame,
+ clusters = this.clusters,
+ index = undefined;
+
+ if (this.clusters) {
+ // try to find the frame where the clusters are located in
+ while (e.parentNode && e.parentNode !== frame) {
+ e = e.parentNode;
+ }
+
+ if (e.parentNode === frame) {
+ // yes! we have found the parent element of all clusters
+ // retrieve its id from the array with clusters
+ for (var i = 0, iMax = clusters.length; i < iMax; i++) {
+ if (clusters[i].dom === e) {
+ index = i;
+ break;
+ }
+ }
+ }
+ }
+
+ return index;
+};
+
+/**
+ * Find all elements within the start and end range
+ * If no element is found, returns an empty array
+ * @param start time
+ * @param end time
+ * @return Array itemsInRange
+ */
+links.Timeline.prototype.getVisibleItems = function (start, end) {
+ var items = this.items;
+ var itemsInRange = [];
+
+ if (items) {
+ for (var i = 0, iMax = items.length; i < iMax; i++) {
+ var item = items[i];
+ if (item.end) {
+ // Time range object // NH use getLeft and getRight here
+ if (start <= item.start && item.end <= end) {
+ itemsInRange.push({"row": i});
+ }
+ } else {
+ // Point object
+ if (start <= item.start && item.start <= end) {
+ itemsInRange.push({"row": i});
+ }
+ }
+ }
+ }
+
+ // var sel = [];
+ // if (this.selection) {
+ // sel.push({"row": this.selection.index});
+ // }
+ // return sel;
+
+ return itemsInRange;
+};
+
+
+/**
+ * Set a new size for the timeline
+ * @param {string} width Width in pixels or percentage (for example "800px"
+ * or "50%")
+ * @param {string} height Height in pixels or percentage (for example "400px"
+ * or "30%")
+ */
+links.Timeline.prototype.setSize = function (width, height) {
+ if (width) {
+ this.options.width = width;
+ this.dom.frame.style.width = width;
+ }
+ if (height) {
+ this.options.height = height;
+ this.options.autoHeight = (this.options.height === "auto");
+ if (height !== "auto" ) {
+ this.dom.frame.style.height = height;
+ }
+ }
+
+ this.render({
+ animate: false
+ });
+};
+
+
+/**
+ * Set a new value for the visible range int the timeline.
+ * Set start undefined to include everything from the earliest date to end.
+ * Set end undefined to include everything from start to the last date.
+ * Example usage:
+ * myTimeline.setVisibleChartRange(new Date("2010-08-22"),
+ * new Date("2010-09-13"));
+ * @param {Date} start The start date for the timeline. optional
+ * @param {Date} end The end date for the timeline. optional
+ * @param {boolean} redraw Optional. If true (default) the Timeline is
+ * directly redrawn
+ */
+links.Timeline.prototype.setVisibleChartRange = function (start, end, redraw) {
+ var range = {};
+ if (!start || !end) {
+ // retrieve the date range of the items
+ range = this.getDataRange(true);
+ }
+
+ if (!start) {
+ if (end) {
+ if (range.min && range.min.valueOf() < end.valueOf()) {
+ // start of the data
+ start = range.min;
+ } else {
+ // 7 days before the end
+ start = new Date(end.valueOf());
+ start.setDate(start.getDate() - 7);
+ }
+ } else {
+ // default of 3 days ago
+ start = new Date();
+ start.setDate(start.getDate() - 3);
+ }
+ }
+
+ if (!end) {
+ if (range.max) {
+ // end of the data
+ end = range.max;
+ } else {
+ // 7 days after start
+ end = new Date(start.valueOf());
+ end.setDate(end.getDate() + 7);
+ }
+ }
+
+ // prevent start Date <= end Date
+ if (end <= start) {
+ end = new Date(start.valueOf());
+ end.setDate(end.getDate() + 7);
+ }
+
+ // limit to the allowed range (don't let this do by applyRange,
+ // because that method will try to maintain the interval (end-start)
+ var min = this.options.min ? this.options.min : undefined; // date
+ if (min != undefined && start.valueOf() < min.valueOf()) {
+ start = new Date(min.valueOf()); // date
+ }
+ var max = this.options.max ? this.options.max : undefined; // date
+ if (max != undefined && end.valueOf() > max.valueOf()) {
+ end = new Date(max.valueOf()); // date
+ }
+
+ this.applyRange(start, end);
+
+ if (redraw == undefined || redraw == true) {
+ this.render({
+ animate: false
+ }); // TODO: optimize, no reflow needed
+ } else {
+ this.recalcConversion();
+ }
+};
+
+
+/**
+ * Change the visible chart range such that all items become visible
+ */
+links.Timeline.prototype.setVisibleChartRangeAuto = function () {
+ var range = this.getDataRange(true);
+ this.setVisibleChartRange(range.min, range.max);
+};
+
+/**
+ * Adjust the visible range such that the current time is located in the center
+ * of the timeline
+ */
+links.Timeline.prototype.setVisibleChartRangeNow = function () {
+ var now = new Date();
+
+ var diff = (this.end.valueOf() - this.start.valueOf());
+
+ var startNew = new Date(now.valueOf() - diff/2);
+ var endNew = new Date(startNew.valueOf() + diff);
+ this.setVisibleChartRange(startNew, endNew);
+};
+
+
+/**
+ * Retrieve the current visible range in the timeline.
+ * @return {Object} An object with start and end properties
+ */
+links.Timeline.prototype.getVisibleChartRange = function () {
+ return {
+ 'start': new Date(this.start.valueOf()),
+ 'end': new Date(this.end.valueOf())
+ };
+};
+
+/**
+ * Get the date range of the items.
+ * @param {boolean} [withMargin] If true, 5% of whitespace is added to the
+ * left and right of the range. Default is false.
+ * @return {Object} range An object with parameters min and max.
+ * - {Date} min is the lowest start date of the items
+ * - {Date} max is the highest start or end date of the items
+ * If no data is available, the values of min and max
+ * will be undefined
+ */
+links.Timeline.prototype.getDataRange = function (withMargin) {
+ var items = this.items,
+ min = undefined, // number
+ max = undefined; // number
+
+ if (items) {
+ for (var i = 0, iMax = items.length; i < iMax; i++) {
+ var item = items[i],
+ start = item.start != undefined ? item.start.valueOf() : undefined,
+ end = item.end != undefined ? item.end.valueOf() : start;
+
+ if (start != undefined) {
+ min = (min != undefined) ? Math.min(min.valueOf(), start.valueOf()) : start;
+ }
+
+ if (end != undefined) {
+ max = (max != undefined) ? Math.max(max.valueOf(), end.valueOf()) : end;
+ }
+ }
+ }
+
+ if (min && max && withMargin) {
+ // zoom out 5% such that you have a little white space on the left and right
+ var diff = (max - min);
+ min = min - diff * 0.05;
+ max = max + diff * 0.05;
+ }
+
+ return {
+ 'min': min != undefined ? new Date(min) : undefined,
+ 'max': max != undefined ? new Date(max) : undefined
+ };
+};
+
+/**
+ * Re-render (reflow and repaint) all components of the Timeline: frame, axis,
+ * items, ...
+ * @param {Object} [options] Available options:
+ * {boolean} renderTimesLeft Number of times the
+ * render may be repeated
+ * 5 times by default.
+ * {boolean} animate takes options.animate
+ * as default value
+ */
+links.Timeline.prototype.render = function (options) {
+ var frameResized = this.reflowFrame();
+ var axisResized = this.reflowAxis();
+ var groupsResized = this.reflowGroups();
+ var itemsResized = this.reflowItems();
+ var resized = (frameResized || axisResized || groupsResized || itemsResized);
+
+ // TODO: only stackEvents/filterItems when resized or changed. (gives a bootstrap issue).
+ // if (resized) {
+ var animate = this.options.animate;
+ if (options && options.animate != undefined) {
+ animate = options.animate;
+ }
+
+ this.recalcConversion();
+ this.clusterItems();
+ this.filterItems();
+ this.stackItems(animate);
+ this.recalcItems();
+
+ // TODO: only repaint when resized or when filterItems or stackItems gave a change?
+ var needsReflow = this.repaint();
+
+ // re-render once when needed (prevent endless re-render loop)
+ if (needsReflow) {
+ var renderTimesLeft = options ? options.renderTimesLeft : undefined;
+ if (renderTimesLeft == undefined) {
+ renderTimesLeft = 5;
+ }
+ if (renderTimesLeft > 0) {
+ this.render({
+ 'animate': options ? options.animate: undefined,
+ 'renderTimesLeft': (renderTimesLeft - 1)
+ });
+ }
+ }
+};
+
+/**
+ * Repaint all components of the Timeline
+ * @return {boolean} needsReflow Returns true if the DOM is changed such that
+ * a reflow is needed.
+ */
+links.Timeline.prototype.repaint = function () {
+ var frameNeedsReflow = this.repaintFrame();
+ var axisNeedsReflow = this.repaintAxis();
+ var groupsNeedsReflow = this.repaintGroups();
+ var itemsNeedsReflow = this.repaintItems();
+ this.repaintCurrentTime();
+ this.repaintCustomTime();
+
+ return (frameNeedsReflow || axisNeedsReflow || groupsNeedsReflow || itemsNeedsReflow);
+};
+
+/**
+ * Reflow the timeline frame
+ * @return {boolean} resized Returns true if any of the frame elements
+ * have been resized.
+ */
+links.Timeline.prototype.reflowFrame = function () {
+ var dom = this.dom,
+ options = this.options,
+ size = this.size,
+ resized = false;
+
+ // Note: IE7 has issues with giving frame.clientWidth, therefore I use offsetWidth instead
+ var frameWidth = dom.frame ? dom.frame.offsetWidth : 0,
+ frameHeight = dom.frame ? dom.frame.clientHeight : 0;
+
+ resized = resized || (size.frameWidth !== frameWidth);
+ resized = resized || (size.frameHeight !== frameHeight);
+ size.frameWidth = frameWidth;
+ size.frameHeight = frameHeight;
+
+ return resized;
+};
+
+/**
+ * repaint the Timeline frame
+ * @return {boolean} needsReflow Returns true if the DOM is changed such that
+ * a reflow is needed.
+ */
+links.Timeline.prototype.repaintFrame = function () {
+ var needsReflow = false,
+ dom = this.dom,
+ options = this.options,
+ size = this.size;
+
+ // main frame
+ if (!dom.frame) {
+ dom.frame = document.createElement("DIV");
+ dom.frame.className = "timeline-frame ui-widget ui-widget-content ui-corner-all";
+ dom.container.appendChild(dom.frame);
+ needsReflow = true;
+ }
+
+ var height = options.autoHeight ?
+ (size.actualHeight + "px") :
+ (options.height || "100%");
+ var width = options.width || "100%";
+ needsReflow = needsReflow || (dom.frame.style.height != height);
+ needsReflow = needsReflow || (dom.frame.style.width != width);
+ dom.frame.style.height = height;
+ dom.frame.style.width = width;
+
+ // contents
+ if (!dom.content) {
+ // create content box where the axis and items will be created
+ dom.content = document.createElement("DIV");
+ dom.content.className = "timeline-content";
+ dom.frame.appendChild(dom.content);
+
+ var timelines = document.createElement("DIV");
+ timelines.style.position = "absolute";
+ timelines.style.left = "0px";
+ timelines.style.top = "0px";
+ timelines.style.height = "100%";
+ timelines.style.width = "0px";
+ dom.content.appendChild(timelines);
+ dom.contentTimelines = timelines;
+
+ var params = this.eventParams,
+ me = this;
+ if (!params.onMouseDown) {
+ params.onMouseDown = function (event) {
+me.onMouseDown(event);};
+ links.Timeline.addEventListener(dom.content, "mousedown", params.onMouseDown);
+ }
+ if (!params.onTouchStart) {
+ params.onTouchStart = function (event) {
+me.onTouchStart(event);};
+ links.Timeline.addEventListener(dom.content, "touchstart", params.onTouchStart);
+ }
+ if (!params.onMouseWheel) {
+ params.onMouseWheel = function (event) {
+me.onMouseWheel(event);};
+ links.Timeline.addEventListener(dom.content, "mousewheel", params.onMouseWheel);
+ }
+ if (!params.onDblClick) {
+ params.onDblClick = function (event) {
+me.onDblClick(event);};
+ links.Timeline.addEventListener(dom.content, "dblclick", params.onDblClick);
+ }
+
+ needsReflow = true;
+ }
+ dom.content.style.left = size.contentLeft + "px";
+ dom.content.style.top = "0px";
+ dom.content.style.width = size.contentWidth + "px";
+ dom.content.style.height = size.frameHeight + "px";
+
+ this.repaintNavigation();
+
+ return needsReflow;
+};
+
+/**
+ * Reflow the timeline axis. Calculate its height, width, positioning, etc...
+ * @return {boolean} resized returns true if the axis is resized
+ */
+links.Timeline.prototype.reflowAxis = function () {
+ var resized = false,
+ dom = this.dom,
+ options = this.options,
+ size = this.size,
+ axisDom = dom.axis;
+
+ var characterMinorWidth = (axisDom && axisDom.characterMinor) ? axisDom.characterMinor.clientWidth : 0,
+ characterMinorHeight = (axisDom && axisDom.characterMinor) ? axisDom.characterMinor.clientHeight : 0,
+ characterMajorWidth = (axisDom && axisDom.characterMajor) ? axisDom.characterMajor.clientWidth : 0,
+ characterMajorHeight = (axisDom && axisDom.characterMajor) ? axisDom.characterMajor.clientHeight : 0,
+ axisHeight = (options.showMinorLabels ? characterMinorHeight : 0) +
+ (options.showMajorLabels ? characterMajorHeight : 0);
+
+ var axisTop = options.axisOnTop ? 0 : size.frameHeight - axisHeight,
+ axisLine = options.axisOnTop ? axisHeight : axisTop;
+
+ resized = resized || (size.axis.top !== axisTop);
+ resized = resized || (size.axis.line !== axisLine);
+ resized = resized || (size.axis.height !== axisHeight);
+ size.axis.top = axisTop;
+ size.axis.line = axisLine;
+ size.axis.height = axisHeight;
+ size.axis.labelMajorTop = options.axisOnTop ? 0 : axisLine +
+ (options.showMinorLabels ? characterMinorHeight : 0);
+ size.axis.labelMinorTop = options.axisOnTop ?
+ (options.showMajorLabels ? characterMajorHeight : 0) :
+ axisLine;
+ size.axis.lineMinorTop = options.axisOnTop ? size.axis.labelMinorTop : 0;
+ size.axis.lineMinorHeight = options.showMajorLabels ?
+ size.frameHeight - characterMajorHeight:
+ size.frameHeight;
+ if (axisDom && axisDom.minorLines && axisDom.minorLines.length) {
+ size.axis.lineMinorWidth = axisDom.minorLines[0].offsetWidth;
+ } else {
+ size.axis.lineMinorWidth = 1;
+ }
+ if (axisDom && axisDom.majorLines && axisDom.majorLines.length) {
+ size.axis.lineMajorWidth = axisDom.majorLines[0].offsetWidth;
+ } else {
+ size.axis.lineMajorWidth = 1;
+ }
+
+ resized = resized || (size.axis.characterMinorWidth !== characterMinorWidth);
+ resized = resized || (size.axis.characterMinorHeight !== characterMinorHeight);
+ resized = resized || (size.axis.characterMajorWidth !== characterMajorWidth);
+ resized = resized || (size.axis.characterMajorHeight !== characterMajorHeight);
+ size.axis.characterMinorWidth = characterMinorWidth;
+ size.axis.characterMinorHeight = characterMinorHeight;
+ size.axis.characterMajorWidth = characterMajorWidth;
+ size.axis.characterMajorHeight = characterMajorHeight;
+
+ var contentHeight = Math.max(size.frameHeight - axisHeight, 0);
+ size.contentLeft = options.groupsOnRight ? 0 : size.groupsWidth;
+ size.contentWidth = Math.max(size.frameWidth - size.groupsWidth, 0);
+ size.contentHeight = contentHeight;
+
+ return resized;
+};
+
+/**
+ * Redraw the timeline axis with minor and major labels
+ * @return {boolean} needsReflow Returns true if the DOM is changed such
+ * that a reflow is needed.
+ */
+links.Timeline.prototype.repaintAxis = function () {
+ var needsReflow = false,
+ dom = this.dom,
+ options = this.options,
+ size = this.size,
+ step = this.step;
+
+ var axis = dom.axis;
+ if (!axis) {
+ axis = {};
+ dom.axis = axis;
+ }
+ if (!size.axis.properties) {
+ size.axis.properties = {};
+ }
+ if (!axis.minorTexts) {
+ axis.minorTexts = [];
+ }
+ if (!axis.minorLines) {
+ axis.minorLines = [];
+ }
+ if (!axis.majorTexts) {
+ axis.majorTexts = [];
+ }
+ if (!axis.majorLines) {
+ axis.majorLines = [];
+ }
+
+ if (!axis.frame) {
+ axis.frame = document.createElement("DIV");
+ axis.frame.style.position = "absolute";
+ axis.frame.style.left = "0px";
+ axis.frame.style.top = "0px";
+ dom.content.appendChild(axis.frame);
+ }
+
+ // take axis offline
+ dom.content.removeChild(axis.frame);
+
+ axis.frame.style.width = (size.contentWidth) + "px";
+ axis.frame.style.height = (size.axis.height) + "px";
+
+ // the drawn axis is more wide than the actual visual part, such that
+ // the axis can be dragged without having to redraw it each time again.
+ var start = this.screenToTime(0);
+ var end = this.screenToTime(size.contentWidth);
+
+ // calculate minimum step (in milliseconds) based on character size
+ if (size.axis.characterMinorWidth) {
+ this.minimumStep = this.screenToTime(size.axis.characterMinorWidth * 6) -
+ this.screenToTime(0);
+
+ step.setRange(start, end, this.minimumStep);
+ }
+
+ var charsNeedsReflow = this.repaintAxisCharacters();
+ needsReflow = needsReflow || charsNeedsReflow;
+
+ // The current labels on the axis will be re-used (much better performance),
+ // therefore, the repaintAxis method uses the mechanism with
+ // repaintAxisStartOverwriting, repaintAxisEndOverwriting, and
+ // this.size.axis.properties is used.
+ this.repaintAxisStartOverwriting();
+
+ step.start();
+ var xFirstMajorLabel = undefined;
+ var max = 0;
+ while (!step.end() && max < 1000) {
+ max++;
+ var cur = step.getCurrent(),
+ x = this.timeToScreen(cur),
+ isMajor = step.isMajor();
+
+ if (options.showMinorLabels) {
+ this.repaintAxisMinorText(x, step.getLabelMinor(options));
+ }
+
+ if (isMajor && options.showMajorLabels) {
+ if (x > 0) {
+ if (xFirstMajorLabel == undefined) {
+ xFirstMajorLabel = x;
+ }
+ this.repaintAxisMajorText(x, step.getLabelMajor(options));
+ }
+ this.repaintAxisMajorLine(x);
+ } else {
+ this.repaintAxisMinorLine(x);
+ }
+
+ step.next();
+ }
+
+ // create a major label on the left when needed
+ if (options.showMajorLabels) {
+ var leftTime = this.screenToTime(0),
+ leftText = this.step.getLabelMajor(options, leftTime),
+ width = leftText.length * size.axis.characterMajorWidth + 10; // upper bound estimation
+
+ if (xFirstMajorLabel == undefined || width < xFirstMajorLabel) {
+ this.repaintAxisMajorText(0, leftText, leftTime);
+ }
+ }
+
+ // cleanup left over labels
+ this.repaintAxisEndOverwriting();
+
+ this.repaintAxisHorizontal();
+
+ // put axis online
+ dom.content.insertBefore(axis.frame, dom.content.firstChild);
+
+ return needsReflow;
+};
+
+/**
+ * Create characters used to determine the size of text on the axis
+ * @return {boolean} needsReflow Returns true if the DOM is changed such that
+ * a reflow is needed.
+ */
+links.Timeline.prototype.repaintAxisCharacters = 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 needsReflow = false,
+ dom = this.dom,
+ axis = dom.axis,
+ text;
+
+ if (!axis.characterMinor) {
+ text = document.createTextNode("0");
+ var characterMinor = document.createElement("DIV");
+ characterMinor.className = "timeline-axis-text timeline-axis-text-minor";
+ characterMinor.appendChild(text);
+ characterMinor.style.position = "absolute";
+ characterMinor.style.visibility = "hidden";
+ characterMinor.style.paddingLeft = "0px";
+ characterMinor.style.paddingRight = "0px";
+ axis.frame.appendChild(characterMinor);
+
+ axis.characterMinor = characterMinor;
+ needsReflow = true;
+ }
+
+ if (!axis.characterMajor) {
+ text = document.createTextNode("0");
+ var characterMajor = document.createElement("DIV");
+ characterMajor.className = "timeline-axis-text timeline-axis-text-major";
+ characterMajor.appendChild(text);
+ characterMajor.style.position = "absolute";
+ characterMajor.style.visibility = "hidden";
+ characterMajor.style.paddingLeft = "0px";
+ characterMajor.style.paddingRight = "0px";
+ axis.frame.appendChild(characterMajor);
+
+ axis.characterMajor = characterMajor;
+ needsReflow = true;
+ }
+
+ return needsReflow;
+};
+
+/**
+ * Initialize redraw of the axis. All existing labels and lines will be
+ * overwritten and reused.
+ */
+links.Timeline.prototype.repaintAxisStartOverwriting = function () {
+ var properties = this.size.axis.properties;
+
+ properties.minorTextNum = 0;
+ properties.minorLineNum = 0;
+ properties.majorTextNum = 0;
+ properties.majorLineNum = 0;
+};
+
+/**
+ * End of overwriting HTML DOM elements of the axis.
+ * remaining elements will be removed
+ */
+links.Timeline.prototype.repaintAxisEndOverwriting = function () {
+ var dom = this.dom,
+ props = this.size.axis.properties,
+ frame = this.dom.axis.frame,
+ num;
+
+ // remove leftovers
+ var minorTexts = dom.axis.minorTexts;
+ num = props.minorTextNum;
+ while (minorTexts.length > num) {
+ var minorText = minorTexts[num];
+ frame.removeChild(minorText);
+ minorTexts.splice(num, 1);
+ }
+
+ var minorLines = dom.axis.minorLines;
+ num = props.minorLineNum;
+ while (minorLines.length > num) {
+ var minorLine = minorLines[num];
+ frame.removeChild(minorLine);
+ minorLines.splice(num, 1);
+ }
+
+ var majorTexts = dom.axis.majorTexts;
+ num = props.majorTextNum;
+ while (majorTexts.length > num) {
+ var majorText = majorTexts[num];
+ frame.removeChild(majorText);
+ majorTexts.splice(num, 1);
+ }
+
+ var majorLines = dom.axis.majorLines;
+ num = props.majorLineNum;
+ while (majorLines.length > num) {
+ var majorLine = majorLines[num];
+ frame.removeChild(majorLine);
+ majorLines.splice(num, 1);
+ }
+};
+
+/**
+ * Repaint the horizontal line and background of the axis
+ */
+links.Timeline.prototype.repaintAxisHorizontal = function () {
+ var axis = this.dom.axis,
+ size = this.size,
+ options = this.options;
+
+ // line behind all axis elements (possibly having a background color)
+ var hasAxis = (options.showMinorLabels || options.showMajorLabels);
+ if (hasAxis) {
+ if (!axis.backgroundLine) {
+ // create the axis line background (for a background color or so)
+ var backgroundLine = document.createElement("DIV");
+ backgroundLine.className = "timeline-axis";
+ backgroundLine.style.position = "absolute";
+ backgroundLine.style.left = "0px";
+ backgroundLine.style.width = "100%";
+ backgroundLine.style.border = "none";
+ axis.frame.insertBefore(backgroundLine, axis.frame.firstChild);
+
+ axis.backgroundLine = backgroundLine;
+ }
+
+ if (axis.backgroundLine) {
+ axis.backgroundLine.style.top = size.axis.top + "px";
+ axis.backgroundLine.style.height = size.axis.height + "px";
+ }
+ } else {
+ if (axis.backgroundLine) {
+ axis.frame.removeChild(axis.backgroundLine);
+ delete axis.backgroundLine;
+ }
+ }
+
+ // line before all axis elements
+ if (hasAxis) {
+ if (axis.line) {
+ // put this line at the end of all childs
+ var line = axis.frame.removeChild(axis.line);
+ axis.frame.appendChild(line);
+ } else {
+ // make the axis line
+ var line = document.createElement("DIV");
+ line.className = "timeline-axis";
+ line.style.position = "absolute";
+ line.style.left = "0px";
+ line.style.width = "100%";
+ line.style.height = "0px";
+ axis.frame.appendChild(line);
+
+ axis.line = line;
+ }
+
+ axis.line.style.top = size.axis.line + "px";
+ } else {
+ if (axis.line && axis.line.parentElement) {
+ axis.frame.removeChild(axis.line);
+ delete axis.line;
+ }
+ }
+};
+
+/**
+ * Create a minor label for the axis at position x
+ * @param {Number} x
+ * @param {String} text
+ */
+links.Timeline.prototype.repaintAxisMinorText = function (x, text) {
+ var size = this.size,
+ dom = this.dom,
+ props = size.axis.properties,
+ frame = dom.axis.frame,
+ minorTexts = dom.axis.minorTexts,
+ index = props.minorTextNum,
+ label;
+
+ if (index < minorTexts.length) {
+ label = minorTexts[index]
+ } else {
+ // create new label
+ var content = document.createTextNode("");
+ label = document.createElement("DIV");
+ label.appendChild(content);
+ label.className = "timeline-axis-text timeline-axis-text-minor";
+ label.style.position = "absolute";
+
+ frame.appendChild(label);
+
+ minorTexts.push(label);
+ }
+
+ label.childNodes[0].nodeValue = text;
+ label.style.left = x + "px";
+ label.style.top = size.axis.labelMinorTop + "px";
+ //label.title = title; // TODO: this is a heavy operation
+
+ props.minorTextNum++;
+};
+
+/**
+ * Create a minor line for the axis at position x
+ * @param {Number} x
+ */
+links.Timeline.prototype.repaintAxisMinorLine = function (x) {
+ var axis = this.size.axis,
+ dom = this.dom,
+ props = axis.properties,
+ frame = dom.axis.frame,
+ minorLines = dom.axis.minorLines,
+ index = props.minorLineNum,
+ line;
+
+ if (index < minorLines.length) {
+ line = minorLines[index];
+ } else {
+ // create vertical line
+ line = document.createElement("DIV");
+ line.className = "timeline-axis-grid timeline-axis-grid-minor";
+ line.style.position = "absolute";
+ line.style.width = "0px";
+
+ frame.appendChild(line);
+ minorLines.push(line);
+ }
+
+ line.style.top = axis.lineMinorTop + "px";
+ line.style.height = axis.lineMinorHeight + "px";
+ line.style.left = (x - axis.lineMinorWidth/2) + "px";
+
+ props.minorLineNum++;
+};
+
+/**
+ * Create a Major label for the axis at position x
+ * @param {Number} x
+ * @param {String} text
+ */
+links.Timeline.prototype.repaintAxisMajorText = function (x, text) {
+ var size = this.size,
+ props = size.axis.properties,
+ frame = this.dom.axis.frame,
+ majorTexts = this.dom.axis.majorTexts,
+ index = props.majorTextNum,
+ label;
+
+ if (index < majorTexts.length) {
+ label = majorTexts[index];
+ } else {
+ // create label
+ var content = document.createTextNode(text);
+ label = document.createElement("DIV");
+ label.className = "timeline-axis-text timeline-axis-text-major";
+ label.appendChild(content);
+ label.style.position = "absolute";
+ label.style.top = "0px";
+
+ frame.appendChild(label);
+ majorTexts.push(label);
+ }
+
+ label.childNodes[0].nodeValue = text;
+ label.style.top = size.axis.labelMajorTop + "px";
+ label.style.left = x + "px";
+ //label.title = title; // TODO: this is a heavy operation
+
+ props.majorTextNum ++;
+};
+
+/**
+ * Create a Major line for the axis at position x
+ * @param {Number} x
+ */
+links.Timeline.prototype.repaintAxisMajorLine = function (x) {
+ var size = this.size,
+ props = size.axis.properties,
+ axis = this.size.axis,
+ frame = this.dom.axis.frame,
+ majorLines = this.dom.axis.majorLines,
+ index = props.majorLineNum,
+ line;
+
+ if (index < majorLines.length) {
+ line = majorLines[index];
+ } else {
+ // create vertical line
+ line = document.createElement("DIV");
+ line.className = "timeline-axis-grid timeline-axis-grid-major";
+ line.style.position = "absolute";
+ line.style.top = "0px";
+ line.style.width = "0px";
+
+ frame.appendChild(line);
+ majorLines.push(line);
+ }
+
+ line.style.left = (x - axis.lineMajorWidth/2) + "px";
+ line.style.height = size.frameHeight + "px";
+
+ props.majorLineNum ++;
+};
+
+/**
+ * Reflow all items, retrieve their actual size
+ * @return {boolean} resized returns true if any of the items is resized
+ */
+links.Timeline.prototype.reflowItems = function () {
+ var resized = false,
+ i,
+ iMax,
+ group,
+ groups = this.groups,
+ renderedItems = this.renderedItems;
+
+ if (groups) { // TODO: need to check if labels exists?
+ // loop through all groups to reset the items height
+ groups.forEach(function (group) {
+ group.itemsHeight = group.labelHeight || 0;
+ });
+ }
+
+ // loop through the width and height of all visible items
+ for (i = 0, iMax = renderedItems.length; i < iMax; i++) {
+ var item = renderedItems[i],
+ domItem = item.dom;
+ group = item.group;
+
+ if (domItem) {
+ // TODO: move updating width and height into item.reflow
+ var width = domItem ? domItem.clientWidth : 0;
+ var height = domItem ? domItem.clientHeight : 0;
+ resized = resized || (item.width != width);
+ resized = resized || (item.height != height);
+ item.width = width;
+ item.height = height;
+ //item.borderWidth = (domItem.offsetWidth - domItem.clientWidth - 2) / 2; // TODO: borderWidth
+ item.reflow();
+ }
+
+ if (group) {
+ group.itemsHeight = Math.max(
+ this.options.groupMinHeight,
+ group.itemsHeight ?
+ Math.max(group.itemsHeight, item.height) :
+ item.height
+ );
+ }
+ }
+
+ return resized;
+};
+
+/**
+ * Recalculate item properties:
+ * - the height of each group.
+ * - the actualHeight, from the stacked items or the sum of the group heights
+ * @return {boolean} resized returns true if any of the items properties is
+ * changed
+ */
+links.Timeline.prototype.recalcItems = function () {
+ var resized = false,
+ i,
+ iMax,
+ item,
+ finalItem,
+ finalItems,
+ group,
+ groups = this.groups,
+ size = this.size,
+ options = this.options,
+ renderedItems = this.renderedItems;
+
+ var actualHeight = 0;
+ if (groups.length == 0) {
+ // calculate actual height of the timeline when there are no groups
+ // but stacked items
+ if (options.autoHeight || options.cluster) {
+ var min = 0,
+ max = 0;
+
+ if (this.stack && this.stack.finalItems) {
+ // adjust the offset of all finalItems when the actualHeight has been changed
+ finalItems = this.stack.finalItems;
+ finalItem = finalItems[0];
+ if (finalItem && finalItem.top) {
+ min = finalItem.top;
+ max = finalItem.top + finalItem.height;
+ }
+ for (i = 1, iMax = finalItems.length; i < iMax; i++) {
+ finalItem = finalItems[i];
+ min = Math.min(min, finalItem.top);
+ max = Math.max(max, finalItem.top + finalItem.height);
+ }
+ } else {
+ item = renderedItems[0];
+ if (item && item.top) {
+ min = item.top;
+ max = item.top + item.height;
+ }
+ for (i = 1, iMax = renderedItems.length; i < iMax; i++) {
+ item = renderedItems[i];
+ if (item.top) {
+ min = Math.min(min, item.top);
+ max = Math.max(max, (item.top + item.height));
+ }
+ }
+ }
+
+ actualHeight = (max - min) + 2 * options.eventMarginAxis + size.axis.height;
+ if (actualHeight < options.minHeight) {
+ actualHeight = options.minHeight;
+ }
+
+ if (size.actualHeight != actualHeight && options.autoHeight && !options.axisOnTop) {
+ // adjust the offset of all items when the actualHeight has been changed
+ var diff = actualHeight - size.actualHeight;
+ if (this.stack && this.stack.finalItems) {
+ finalItems = this.stack.finalItems;
+ for (i = 0, iMax = finalItems.length; i < iMax; i++) {
+ finalItems[i].top += diff;
+ finalItems[i].item.top += diff;
+ }
+ } else {
+ for (i = 0, iMax = renderedItems.length; i < iMax; i++) {
+ renderedItems[i].top += diff;
+ }
+ }
+ }
+ }
+ } else {
+ // loop through all groups to get the height of each group, and the
+ // total height
+ actualHeight = size.axis.height + 2 * options.eventMarginAxis;
+ for (i = 0, iMax = groups.length; i < iMax; i++) {
+ group = groups[i];
+
+ //
+ // TODO: Do we want to apply a max height? how ?
+ //
+ var groupHeight = group.itemsHeight;
+ resized = resized || (groupHeight != group.height);
+ group.height = Math.max(groupHeight, options.groupMinHeight);
+
+ actualHeight += groups[i].height + options.eventMargin;
+ }
+
+ // calculate top positions of the group labels and lines
+ var eventMargin = options.eventMargin,
+ top = options.axisOnTop ?
+ options.eventMarginAxis + eventMargin/2 :
+ size.contentHeight - options.eventMarginAxis + eventMargin/ 2,
+ axisHeight = size.axis.height;
+
+ for (i = 0, iMax = groups.length; i < iMax; i++) {
+ group = groups[i];
+ if (options.axisOnTop) {
+ group.top = top + axisHeight;
+ group.labelTop = top + axisHeight + (group.height - group.labelHeight) / 2;
+ group.lineTop = top + axisHeight + group.height + eventMargin/2;
+ top += group.height + eventMargin;
+ } else {
+ top -= group.height + eventMargin;
+ group.top = top;
+ group.labelTop = top + (group.height - group.labelHeight) / 2;
+ group.lineTop = top - eventMargin/2;
+ }
+ }
+
+ resized = true;
+ }
+
+ if (actualHeight < options.minHeight) {
+ actualHeight = options.minHeight;
+ }
+ resized = resized || (actualHeight != size.actualHeight);
+ size.actualHeight = actualHeight;
+
+ return resized;
+};
+
+/**
+ * This method clears the (internal) array this.items in a safe way: neatly
+ * cleaning up the DOM, and accompanying arrays this.renderedItems and
+ * the created clusters.
+ */
+links.Timeline.prototype.clearItems = function () {
+ // add all visible items to the list to be hidden
+ var hideItems = this.renderQueue.hide;
+ this.renderedItems.forEach(function (item) {
+ hideItems.push(item);
+ });
+
+ // clear the cluster generator
+ this.clusterGenerator.clear();
+
+ // actually clear the items
+ this.items = [];
+};
+
+/**
+ * Repaint all items
+ * @return {boolean} needsReflow Returns true if the DOM is changed such that
+ * a reflow is needed.
+ */
+links.Timeline.prototype.repaintItems = function () {
+ var i, iMax, item, index;
+
+ var needsReflow = false,
+ dom = this.dom,
+ size = this.size,
+ timeline = this,
+ renderedItems = this.renderedItems;
+
+ if (!dom.items) {
+ dom.items = {};
+ }
+
+ // draw the frame containing the items
+ var frame = dom.items.frame;
+ if (!frame) {
+ frame = document.createElement("DIV");
+ frame.style.position = "relative";
+ dom.content.appendChild(frame);
+ dom.items.frame = frame;
+ }
+
+ frame.style.left = "0px";
+ frame.style.top = size.items.top + "px";
+ frame.style.height = "0px";
+
+ // Take frame offline (for faster manipulation of the DOM)
+ dom.content.removeChild(frame);
+
+ // process the render queue with changes
+ var queue = this.renderQueue;
+ var newImageUrls = [];
+ needsReflow = needsReflow ||
+ (queue.show.length > 0) ||
+ (queue.update.length > 0) ||
+ (queue.hide.length > 0); // TODO: reflow needed on hide of items?
+
+ while (item = queue.show.shift()) {
+ item.showDOM(frame);
+ item.getImageUrls(newImageUrls);
+ renderedItems.push(item);
+ }
+ while (item = queue.update.shift()) {
+ item.updateDOM(frame);
+ item.getImageUrls(newImageUrls);
+ index = this.renderedItems.indexOf(item);
+ if (index == -1) {
+ renderedItems.push(item);
+ }
+ }
+ while (item = queue.hide.shift()) {
+ item.hideDOM(frame);
+ index = this.renderedItems.indexOf(item);
+ if (index != -1) {
+ renderedItems.splice(index, 1);
+ }
+ }
+
+ // reposition all visible items
+ renderedItems.forEach(function (item) {
+ item.updatePosition(timeline);
+ });
+
+ // redraw the delete button and dragareas of the selected item (if any)
+ this.repaintDeleteButton();
+ this.repaintDragAreas();
+
+ // put frame online again
+ dom.content.appendChild(frame);
+
+ if (newImageUrls.length) {
+ // retrieve all image sources from the items, and set a callback once
+ // all images are retrieved
+ var callback = function () {
+ timeline.render();
+ };
+ var sendCallbackWhenAlreadyLoaded = false;
+ links.imageloader.loadAll(newImageUrls, callback, sendCallbackWhenAlreadyLoaded);
+ }
+
+ return needsReflow;
+};
+
+/**
+ * Reflow the size of the groups
+ * @return {boolean} resized Returns true if any of the frame elements
+ * have been resized.
+ */
+links.Timeline.prototype.reflowGroups = function () {
+ var resized = false,
+ options = this.options,
+ size = this.size,
+ dom = this.dom;
+
+ // calculate the groups width and height
+ // TODO: only update when data is changed! -> use an updateSeq
+ var groupsWidth = 0;
+
+ // loop through all groups to get the labels width and height
+ var groups = this.groups;
+ var labels = this.dom.groups ? this.dom.groups.labels : [];
+ for (var i = 0, iMax = groups.length; i < iMax; i++) {
+ var group = groups[i];
+ var label = labels[i];
+ group.labelWidth = label ? label.clientWidth : 0;
+ group.labelHeight = label ? label.clientHeight : 0;
+ group.width = group.labelWidth; // TODO: group.width is redundant with labelWidth
+
+ groupsWidth = Math.max(groupsWidth, group.width);
+ }
+
+ // limit groupsWidth to the groups width in the options
+ if (options.groupsWidth !== undefined) {
+ groupsWidth = dom.groups && dom.groups.frame ? dom.groups.frame.clientWidth : 0;
+ }
+
+ // compensate for the border width. TODO: calculate the real border width
+ groupsWidth += 1;
+
+ var groupsLeft = options.groupsOnRight ? size.frameWidth - groupsWidth : 0;
+ resized = resized || (size.groupsWidth !== groupsWidth);
+ resized = resized || (size.groupsLeft !== groupsLeft);
+ size.groupsWidth = groupsWidth;
+ size.groupsLeft = groupsLeft;
+
+ return resized;
+};
+
+/**
+ * Redraw the group labels
+ */
+links.Timeline.prototype.repaintGroups = function () {
+ var dom = this.dom,
+ timeline = this,
+ options = this.options,
+ size = this.size,
+ groups = this.groups;
+
+ if (dom.groups === undefined) {
+ dom.groups = {};
+ }
+
+ var labels = dom.groups.labels;
+ if (!labels) {
+ labels = [];
+ dom.groups.labels = labels;
+ }
+ var labelLines = dom.groups.labelLines;
+ if (!labelLines) {
+ labelLines = [];
+ dom.groups.labelLines = labelLines;
+ }
+ var itemLines = dom.groups.itemLines;
+ if (!itemLines) {
+ itemLines = [];
+ dom.groups.itemLines = itemLines;
+ }
+
+ // create the frame for holding the groups
+ var frame = dom.groups.frame;
+ if (!frame) {
+ frame = document.createElement("DIV");
+ frame.className = "timeline-groups-axis";
+ frame.style.position = "absolute";
+ frame.style.overflow = "hidden";
+ frame.style.top = "0px";
+ frame.style.height = "100%";
+
+ dom.frame.appendChild(frame);
+ dom.groups.frame = frame;
+ }
+
+ frame.style.left = size.groupsLeft + "px";
+ frame.style.width = (options.groupsWidth !== undefined) ?
+ options.groupsWidth :
+ size.groupsWidth + "px";
+
+ // hide groups axis when there are no groups
+ if (groups.length == 0) {
+ frame.style.display = 'none';
+ } else {
+ frame.style.display = '';
+ }
+
+ // TODO: only create/update groups when data is changed.
+
+ // create the items
+ var current = labels.length,
+ needed = groups.length;
+
+ // overwrite existing group labels
+ for (var i = 0, iMax = Math.min(current, needed); i < iMax; i++) {
+ var group = groups[i];
+ var label = labels[i];
+ label.innerHTML = this.getGroupName(group);
+ label.style.display = '';
+ }
+
+ // append new items when needed
+ for (var i = current; i < needed; i++) {
+ var group = groups[i];
+
+ // create text label
+ var label = document.createElement("DIV");
+ label.className = "timeline-groups-text";
+ label.style.position = "absolute";
+ if (options.groupsWidth === undefined) {
+ label.style.whiteSpace = "nowrap";
+ }
+ label.innerHTML = this.getGroupName(group);
+ frame.appendChild(label);
+ labels[i] = label;
+
+ // create the grid line between the group labels
+ var labelLine = document.createElement("DIV");
+ labelLine.className = "timeline-axis-grid timeline-axis-grid-minor";
+ labelLine.style.position = "absolute";
+ labelLine.style.left = "0px";
+ labelLine.style.width = "100%";
+ labelLine.style.height = "0px";
+ labelLine.style.borderTopStyle = "solid";
+ frame.appendChild(labelLine);
+ labelLines[i] = labelLine;
+
+ // create the grid line between the items
+ var itemLine = document.createElement("DIV");
+ itemLine.className = "timeline-axis-grid timeline-axis-grid-minor";
+ itemLine.style.position = "absolute";
+ itemLine.style.left = "0px";
+ itemLine.style.width = "100%";
+ itemLine.style.height = "0px";
+ itemLine.style.borderTopStyle = "solid";
+ dom.content.insertBefore(itemLine, dom.content.firstChild);
+ itemLines[i] = itemLine;
+ }
+
+ // remove redundant items from the DOM when needed
+ for (var i = needed; i < current; i++) {
+ var label = labels[i],
+ labelLine = labelLines[i],
+ itemLine = itemLines[i];
+
+ frame.removeChild(label);
+ frame.removeChild(labelLine);
+ dom.content.removeChild(itemLine);
+ }
+ labels.splice(needed, current - needed);
+ labelLines.splice(needed, current - needed);
+ itemLines.splice(needed, current - needed);
+
+ links.Timeline.addClassName(frame, options.groupsOnRight ? 'timeline-groups-axis-onright' : 'timeline-groups-axis-onleft');
+
+ // position the groups
+ for (var i = 0, iMax = groups.length; i < iMax; i++) {
+ var group = groups[i],
+ label = labels[i],
+ labelLine = labelLines[i],
+ itemLine = itemLines[i];
+
+ label.style.top = group.labelTop + "px";
+ labelLine.style.top = group.lineTop + "px";
+ itemLine.style.top = group.lineTop + "px";
+ itemLine.style.width = size.contentWidth + "px";
+ }
+
+ if (!dom.groups.background) {
+ // create the axis grid line background
+ var background = document.createElement("DIV");
+ background.className = "timeline-axis";
+ background.style.position = "absolute";
+ background.style.left = "0px";
+ background.style.width = "100%";
+ background.style.border = "none";
+
+ frame.appendChild(background);
+ dom.groups.background = background;
+ }
+ dom.groups.background.style.top = size.axis.top + 'px';
+ dom.groups.background.style.height = size.axis.height + 'px';
+
+ if (!dom.groups.line) {
+ // create the axis grid line
+ var line = document.createElement("DIV");
+ line.className = "timeline-axis";
+ line.style.position = "absolute";
+ line.style.left = "0px";
+ line.style.width = "100%";
+ line.style.height = "0px";
+
+ frame.appendChild(line);
+ dom.groups.line = line;
+ }
+ dom.groups.line.style.top = size.axis.line + 'px';
+
+ // create a callback when there are images which are not yet loaded
+ // TODO: more efficiently load images in the groups
+ if (dom.groups.frame && groups.length) {
+ var imageUrls = [];
+ links.imageloader.filterImageUrls(dom.groups.frame, imageUrls);
+ if (imageUrls.length) {
+ // retrieve all image sources from the items, and set a callback once
+ // all images are retrieved
+ var callback = function () {
+ timeline.render();
+ };
+ var sendCallbackWhenAlreadyLoaded = false;
+ links.imageloader.loadAll(imageUrls, callback, sendCallbackWhenAlreadyLoaded);
+ }
+ }
+};
+
+
+/**
+ * Redraw the current time bar
+ */
+links.Timeline.prototype.repaintCurrentTime = function () {
+ var options = this.options,
+ dom = this.dom,
+ size = this.size;
+
+ if (!options.showCurrentTime) {
+ if (dom.currentTime) {
+ dom.contentTimelines.removeChild(dom.currentTime);
+ delete dom.currentTime;
+ }
+
+ return;
+ }
+
+ if (!dom.currentTime) {
+ // create the current time bar
+ var currentTime = document.createElement("DIV");
+ currentTime.className = "timeline-currenttime";
+ currentTime.style.position = "absolute";
+ currentTime.style.top = "0px";
+ currentTime.style.height = "100%";
+
+ dom.contentTimelines.appendChild(currentTime);
+ dom.currentTime = currentTime;
+ }
+
+ var now = new Date();
+ var nowOffset = new Date(now.valueOf() + this.clientTimeOffset);
+ var x = this.timeToScreen(nowOffset);
+
+ var visible = (x > -size.contentWidth && x < 2 * size.contentWidth);
+ dom.currentTime.style.display = visible ? '' : 'none';
+ dom.currentTime.style.left = x + "px";
+ //dom.currentTime.title = "Current time: " + nowOffset;
+
+ // start a timer to adjust for the new time
+ if (this.currentTimeTimer != undefined) {
+ clearTimeout(this.currentTimeTimer);
+ delete this.currentTimeTimer;
+ }
+ var timeline = this;
+ var onTimeout = function () {
+ timeline.repaintCurrentTime();
+ };
+ // the time equal to the width of one pixel, divided by 2 for more smoothness
+ var interval = 1 / this.conversion.factor / 2;
+ if (interval < 30) {
+interval = 30; }
+ this.currentTimeTimer = setTimeout(onTimeout, interval);
+};
+
+/**
+ * Redraw the custom time bar
+ */
+links.Timeline.prototype.repaintCustomTime = function () {
+ var options = this.options,
+ dom = this.dom,
+ size = this.size;
+
+ if (!options.showCustomTime) {
+ if (dom.customTime) {
+ dom.contentTimelines.removeChild(dom.customTime);
+ delete dom.customTime;
+ }
+
+ return;
+ }
+
+ if (!dom.customTime) {
+ var customTime = document.createElement("DIV");
+ customTime.className = "timeline-customtime";
+ customTime.style.position = "absolute";
+ customTime.style.top = "0px";
+ customTime.style.height = "100%";
+
+ var drag = document.createElement("DIV");
+ drag.style.position = "relative";
+ drag.style.top = "0px";
+ drag.style.left = "-10px";
+ drag.style.height = "100%";
+ drag.style.width = "20px";
+ customTime.appendChild(drag);
+
+ dom.contentTimelines.appendChild(customTime);
+ dom.customTime = customTime;
+
+ // initialize parameter
+ this.customTime = new Date();
+ }
+
+ var x = this.timeToScreen(this.customTime),
+ visible = (x > -size.contentWidth && x < 2 * size.contentWidth);
+ dom.customTime.style.display = visible ? '' : 'none';
+ dom.customTime.style.left = x + "px";
+ //dom.customTime.title = "Time: " + this.customTime;
+};
+
+
+/**
+ * Redraw the delete button, on the top right of the currently selected item
+ * if there is no item selected, the button is hidden.
+ */
+links.Timeline.prototype.repaintDeleteButton = function () {
+ var timeline = this,
+ dom = this.dom,
+ frame = dom.items.frame;
+
+ var deleteButton = dom.items.deleteButton;
+ if (!deleteButton) {
+ // create a delete button
+ deleteButton = document.createElement("DIV");
+ deleteButton.className = "timeline-navigation-delete";
+ deleteButton.style.position = "absolute";
+
+ frame.appendChild(deleteButton);
+ dom.items.deleteButton = deleteButton;
+ }
+
+ var index = (this.selection && this.selection.index !== undefined) ? this.selection.index : -1,
+ item = (this.selection && this.selection.index !== undefined) ? this.items[index] : undefined;
+ if (item && item.rendered && this.isEditable(item)) {
+ var right = item.getRight(this),
+ top = item.top;
+
+ deleteButton.style.left = right + 'px';
+ deleteButton.style.top = top + 'px';
+ deleteButton.style.display = '';
+ frame.removeChild(deleteButton);
+ frame.appendChild(deleteButton);
+ } else {
+ deleteButton.style.display = 'none';
+ }
+};
+
+
+/**
+ * Redraw the drag areas. When an item (ranges only) is selected,
+ * it gets a drag area on the left and right side, to change its width
+ */
+links.Timeline.prototype.repaintDragAreas = function () {
+ var timeline = this,
+ options = this.options,
+ dom = this.dom,
+ frame = this.dom.items.frame;
+
+ // create left drag area
+ var dragLeft = dom.items.dragLeft;
+ if (!dragLeft) {
+ dragLeft = document.createElement("DIV");
+ dragLeft.className="timeline-event-range-drag-left";
+ dragLeft.style.position = "absolute";
+
+ frame.appendChild(dragLeft);
+ dom.items.dragLeft = dragLeft;
+ }
+
+ // create right drag area
+ var dragRight = dom.items.dragRight;
+ if (!dragRight) {
+ dragRight = document.createElement("DIV");
+ dragRight.className="timeline-event-range-drag-right";
+ dragRight.style.position = "absolute";
+
+ frame.appendChild(dragRight);
+ dom.items.dragRight = dragRight;
+ }
+
+ // reposition left and right drag area
+ var index = (this.selection && this.selection.index !== undefined) ? this.selection.index : -1,
+ item = (this.selection && this.selection.index !== undefined) ? this.items[index] : undefined;
+ if (item && item.rendered && this.isEditable(item) &&
+ (item instanceof links.Timeline.ItemRange || item instanceof links.Timeline.ItemFloatingRange)) {
+ var left = item.getLeft(this), // NH change to getLeft
+ right = item.getRight(this), // NH change to getRight
+ top = item.top,
+ height = item.height;
+
+ dragLeft.style.left = left + 'px';
+ dragLeft.style.top = top + 'px';
+ dragLeft.style.width = options.dragAreaWidth + "px";
+ dragLeft.style.height = height + 'px';
+ dragLeft.style.display = '';
+ frame.removeChild(dragLeft);
+ frame.appendChild(dragLeft);
+
+ dragRight.style.left = (right - options.dragAreaWidth) + 'px';
+ dragRight.style.top = top + 'px';
+ dragRight.style.width = options.dragAreaWidth + "px";
+ dragRight.style.height = height + 'px';
+ dragRight.style.display = '';
+ frame.removeChild(dragRight);
+ frame.appendChild(dragRight);
+ } else {
+ dragLeft.style.display = 'none';
+ dragRight.style.display = 'none';
+ }
+};
+
+/**
+ * Create the navigation buttons for zooming and moving
+ */
+links.Timeline.prototype.repaintNavigation = function () {
+ var timeline = this,
+ options = this.options,
+ dom = this.dom,
+ frame = dom.frame,
+ navBar = dom.navBar;
+
+ if (!navBar) {
+ var showButtonNew = options.showButtonNew && options.editable;
+ var showNavigation = options.showNavigation && (options.zoomable || options.moveable);
+ if (showNavigation || showButtonNew) {
+ // create a navigation bar containing the navigation buttons
+ navBar = document.createElement("DIV");
+ navBar.style.position = "absolute";
+ navBar.className = "timeline-navigation ui-widget ui-state-highlight ui-corner-all";
+ if (options.groupsOnRight) {
+ navBar.style.left = '10px';
+ } else {
+ navBar.style.right = '10px';
+ }
+ if (options.axisOnTop) {
+ navBar.style.bottom = '10px';
+ } else {
+ navBar.style.top = '10px';
+ }
+ dom.navBar = navBar;
+ frame.appendChild(navBar);
+ }
+
+ if (showButtonNew) {
+ // create a new in button
+ navBar.addButton = document.createElement("DIV");
+ navBar.addButton.className = "timeline-navigation-new";
+ navBar.addButton.title = options.CREATE_NEW_EVENT;
+ var addIconSpan = document.createElement("SPAN");
+ addIconSpan.className = "ui-icon ui-icon-circle-plus";
+ navBar.addButton.appendChild(addIconSpan);
+
+ var onAdd = function (event) {
+ links.Timeline.preventDefault(event);
+ links.Timeline.stopPropagation(event);
+
+ // create a new event at the center of the frame
+ var w = timeline.size.contentWidth;
+ var x = w / 2;
+ var xstart = timeline.screenToTime(x);
+ if (options.snapEvents) {
+ timeline.step.snap(xstart);
+ }
+
+ var content = options.NEW;
+ var group = timeline.groups.length ? timeline.groups[0].content : undefined;
+ var preventRender = true;
+ timeline.addItem({
+ 'start': xstart,
+ 'content': content,
+ 'group': group
+ }, preventRender);
+ var index = (timeline.items.length - 1);
+ timeline.selectItem(index);
+
+ timeline.applyAdd = true;
+
+ // fire an add event.
+ // Note that the change can be canceled from within an event listener if
+ // this listener calls the method cancelAdd().
+ timeline.trigger('add');
+
+ if (timeline.applyAdd) {
+ // render and select the item
+ timeline.render({animate: false});
+ timeline.selectItem(index);
+ } else {
+ // undo an add
+ timeline.deleteItem(index);
+ }
+ };
+ links.Timeline.addEventListener(navBar.addButton, "mousedown", onAdd);
+ navBar.appendChild(navBar.addButton);
+ }
+
+ if (showButtonNew && showNavigation) {
+ // create a separator line
+ links.Timeline.addClassName(navBar.addButton, 'timeline-navigation-new-line');
+ }
+
+ if (showNavigation) {
+ if (options.zoomable) {
+ // create a zoom in button
+ navBar.zoomInButton = document.createElement("DIV");
+ navBar.zoomInButton.className = "timeline-navigation-zoom-in";
+ navBar.zoomInButton.title = this.options.ZOOM_IN;
+ var ziIconSpan = document.createElement("SPAN");
+ ziIconSpan.className = "ui-icon ui-icon-circle-zoomin";
+ navBar.zoomInButton.appendChild(ziIconSpan);
+
+ var onZoomIn = function (event) {
+ links.Timeline.preventDefault(event);
+ links.Timeline.stopPropagation(event);
+ timeline.zoom(0.4);
+ timeline.trigger("rangechange");
+ timeline.trigger("rangechanged");
+ };
+ links.Timeline.addEventListener(navBar.zoomInButton, "mousedown", onZoomIn);
+ navBar.appendChild(navBar.zoomInButton);
+
+ // create a zoom out button
+ navBar.zoomOutButton = document.createElement("DIV");
+ navBar.zoomOutButton.className = "timeline-navigation-zoom-out";
+ navBar.zoomOutButton.title = this.options.ZOOM_OUT;
+ var zoIconSpan = document.createElement("SPAN");
+ zoIconSpan.className = "ui-icon ui-icon-circle-zoomout";
+ navBar.zoomOutButton.appendChild(zoIconSpan);
+
+ var onZoomOut = function (event) {
+ links.Timeline.preventDefault(event);
+ links.Timeline.stopPropagation(event);
+ timeline.zoom(-0.4);
+ timeline.trigger("rangechange");
+ timeline.trigger("rangechanged");
+ };
+ links.Timeline.addEventListener(navBar.zoomOutButton, "mousedown", onZoomOut);
+ navBar.appendChild(navBar.zoomOutButton);
+ }
+
+ if (options.moveable) {
+ // create a move left button
+ navBar.moveLeftButton = document.createElement("DIV");
+ navBar.moveLeftButton.className = "timeline-navigation-move-left";
+ navBar.moveLeftButton.title = this.options.MOVE_LEFT;
+ var mlIconSpan = document.createElement("SPAN");
+ mlIconSpan.className = "ui-icon ui-icon-circle-arrow-w";
+ navBar.moveLeftButton.appendChild(mlIconSpan);
+
+ var onMoveLeft = function (event) {
+ links.Timeline.preventDefault(event);
+ links.Timeline.stopPropagation(event);
+ timeline.move(-0.2);
+ timeline.trigger("rangechange");
+ timeline.trigger("rangechanged");
+ };
+ links.Timeline.addEventListener(navBar.moveLeftButton, "mousedown", onMoveLeft);
+ navBar.appendChild(navBar.moveLeftButton);
+
+ // create a move right button
+ navBar.moveRightButton = document.createElement("DIV");
+ navBar.moveRightButton.className = "timeline-navigation-move-right";
+ navBar.moveRightButton.title = this.options.MOVE_RIGHT;
+ var mrIconSpan = document.createElement("SPAN");
+ mrIconSpan.className = "ui-icon ui-icon-circle-arrow-e";
+ navBar.moveRightButton.appendChild(mrIconSpan);
+
+ var onMoveRight = function (event) {
+ links.Timeline.preventDefault(event);
+ links.Timeline.stopPropagation(event);
+ timeline.move(0.2);
+ timeline.trigger("rangechange");
+ timeline.trigger("rangechanged");
+ };
+ links.Timeline.addEventListener(navBar.moveRightButton, "mousedown", onMoveRight);
+ navBar.appendChild(navBar.moveRightButton);
+ }
+ }
+ }
+};
+
+
+/**
+ * Set current time. This function can be used to set the time in the client
+ * timeline equal with the time on a server.
+ * @param {Date} time
+ */
+links.Timeline.prototype.setCurrentTime = function (time) {
+ var now = new Date();
+ this.clientTimeOffset = (time.valueOf() - now.valueOf());
+
+ this.repaintCurrentTime();
+};
+
+/**
+ * Get current time. The time can have an offset from the real time, when
+ * the current time has been changed via the method setCurrentTime.
+ * @return {Date} time
+ */
+links.Timeline.prototype.getCurrentTime = function () {
+ var now = new Date();
+ return new Date(now.valueOf() + this.clientTimeOffset);
+};
+
+
+/**
+ * Set custom time.
+ * The custom time bar can be used to display events in past or future.
+ * @param {Date} time
+ */
+links.Timeline.prototype.setCustomTime = function (time) {
+ this.customTime = new Date(time.valueOf());
+ this.repaintCustomTime();
+};
+
+/**
+ * Retrieve the current custom time.
+ * @return {Date} customTime
+ */
+links.Timeline.prototype.getCustomTime = function () {
+ return new Date(this.customTime.valueOf());
+};
+
+/**
+ * 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 {links.Timeline.StepDate.SCALE} scale
+ * A scale. Choose from SCALE.MILLISECOND,
+ * SCALE.SECOND, SCALE.MINUTE, SCALE.HOUR,
+ * SCALE.WEEKDAY, SCALE.DAY, SCALE.MONTH,
+ * SCALE.YEAR.
+ * @param {int} step A step size, by default 1. Choose for
+ * example 1, 2, 5, or 10.
+ */
+links.Timeline.prototype.setScale = function (scale, step) {
+ this.step.setScale(scale, step);
+ this.render(); // TODO: optimize: only reflow/repaint axis
+};
+
+/**
+ * Enable or disable autoscaling
+ * @param {boolean} enable If true or not defined, autoscaling is enabled.
+ * If false, autoscaling is disabled.
+ */
+links.Timeline.prototype.setAutoScale = function (enable) {
+ this.step.setAutoScale(enable);
+ this.render(); // TODO: optimize: only reflow/repaint axis
+};
+
+/**
+ * Redraw the timeline
+ * Reloads the (linked) data table and redraws the timeline when resized.
+ * See also the method checkResize
+ */
+links.Timeline.prototype.redraw = function () {
+ this.setData(this.data);
+};
+
+
+/**
+ * Check if the timeline is resized, and if so, redraw the timeline.
+ * Useful when the webpage is resized.
+ */
+links.Timeline.prototype.checkResize = function () {
+ // TODO: re-implement the method checkResize, or better, make it redundant as this.render will be smarter
+ this.render();
+};
+
+/**
+ * Check whether a given item is editable
+ * @param {links.Timeline.Item} item
+ * @return {boolean} editable
+ */
+links.Timeline.prototype.isEditable = function (item) {
+ if (item) {
+ if (item.editable != undefined) {
+ return item.editable;
+ } else {
+ return this.options.editable;
+ }
+ }
+ return false;
+};
+
+/**
+ * Calculate the factor and offset to convert a position on screen to the
+ * corresponding date and vice versa.
+ * After the method calcConversionFactor is executed once, the methods screenToTime and
+ * timeToScreen can be used.
+ */
+links.Timeline.prototype.recalcConversion = function () {
+ this.conversion.offset = this.start.valueOf();
+ this.conversion.factor = this.size.contentWidth /
+ (this.end.valueOf() - this.start.valueOf());
+};
+
+
+/**
+ * Convert a position on screen (pixels) to a datetime
+ * Before this method can be used, the method calcConversionFactor must be
+ * executed once.
+ * @param {int} x Position on the screen in pixels
+ * @return {Date} time The datetime the corresponds with given position x
+ */
+links.Timeline.prototype.screenToTime = 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 calcConversionFactor 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.
+ */
+links.Timeline.prototype.timeToScreen = function (time) {
+ var conversion = this.conversion;
+ return (time.valueOf() - conversion.offset) * conversion.factor;
+};
+
+
+
+/**
+ * Event handler for touchstart event on mobile devices
+ */
+links.Timeline.prototype.onTouchStart = function (event) {
+ var params = this.eventParams,
+ me = this;
+
+ if (params.touchDown) {
+ // if already moving, return
+ return;
+ }
+
+ params.touchDown = true;
+ params.zoomed = false;
+
+ this.onMouseDown(event);
+
+ if (!params.onTouchMove) {
+ params.onTouchMove = function (event) {
+me.onTouchMove(event);};
+ links.Timeline.addEventListener(document, "touchmove", params.onTouchMove);
+ }
+ if (!params.onTouchEnd) {
+ params.onTouchEnd = function (event) {
+me.onTouchEnd(event);};
+ links.Timeline.addEventListener(document, "touchend", params.onTouchEnd);
+ }
+
+ /* TODO
+ // check for double tap event
+ var delta = 500; // ms
+ var doubleTapStart = (new Date()).valueOf();
+ var target = links.Timeline.getTarget(event);
+ var doubleTapItem = this.getItemIndex(target);
+ if (params.doubleTapStart &&
+ (doubleTapStart - params.doubleTapStart) < delta &&
+ doubleTapItem == params.doubleTapItem) {
+ delete params.doubleTapStart;
+ delete params.doubleTapItem;
+ me.onDblClick(event);
+ params.touchDown = false;
+ }
+ params.doubleTapStart = doubleTapStart;
+ params.doubleTapItem = doubleTapItem;
+ */
+ // store timing for double taps
+ var target = links.Timeline.getTarget(event);
+ var item = this.getItemIndex(target);
+ params.doubleTapStartPrev = params.doubleTapStart;
+ params.doubleTapStart = (new Date()).valueOf();
+ params.doubleTapItemPrev = params.doubleTapItem;
+ params.doubleTapItem = item;
+
+ links.Timeline.preventDefault(event);
+};
+
+/**
+ * Event handler for touchmove event on mobile devices
+ */
+links.Timeline.prototype.onTouchMove = function (event) {
+ var params = this.eventParams;
+
+ if (event.scale && event.scale !== 1) {
+ params.zoomed = true;
+ }
+
+ if (!params.zoomed) {
+ // move
+ this.onMouseMove(event);
+ } else {
+ if (this.options.zoomable) {
+ // pinch
+ // TODO: pinch only supported on iPhone/iPad. Create something manually for Android?
+ params.zoomed = true;
+
+ var scale = event.scale,
+ oldWidth = (params.end.valueOf() - params.start.valueOf()),
+ newWidth = oldWidth / scale,
+ diff = newWidth - oldWidth,
+ start = new Date(parseInt(params.start.valueOf() - diff/2)),
+ end = new Date(parseInt(params.end.valueOf() + diff/2));
+
+ // TODO: determine zoom-around-date from touch positions?
+
+ this.setVisibleChartRange(start, end);
+ this.trigger("rangechange");
+ }
+ }
+
+ links.Timeline.preventDefault(event);
+};
+
+/**
+ * Event handler for touchend event on mobile devices
+ */
+links.Timeline.prototype.onTouchEnd = function (event) {
+ var params = this.eventParams;
+ var me = this;
+ params.touchDown = false;
+
+ if (params.zoomed) {
+ this.trigger("rangechanged");
+ }
+
+ if (params.onTouchMove) {
+ links.Timeline.removeEventListener(document, "touchmove", params.onTouchMove);
+ delete params.onTouchMove;
+ }
+ if (params.onTouchEnd) {
+ links.Timeline.removeEventListener(document, "touchend", params.onTouchEnd);
+ delete params.onTouchEnd;
+ }
+
+ this.onMouseUp(event);
+
+ // check for double tap event
+ var delta = 500; // ms
+ var doubleTapEnd = (new Date()).valueOf();
+ var target = links.Timeline.getTarget(event);
+ var doubleTapItem = this.getItemIndex(target);
+ if (params.doubleTapStartPrev &&
+ (doubleTapEnd - params.doubleTapStartPrev) < delta &&
+ params.doubleTapItem == params.doubleTapItemPrev) {
+ params.touchDown = true;
+ me.onDblClick(event);
+ params.touchDown = false;
+ }
+
+ links.Timeline.preventDefault(event);
+};
+
+
+/**
+ * Start a moving operation inside the provided parent element
+ * @param {Event} event The event that occurred (required for
+ * retrieving the mouse position)
+ */
+links.Timeline.prototype.onMouseDown = function (event) {
+ event = event || window.event;
+
+ var params = this.eventParams,
+ options = this.options,
+ dom = this.dom;
+
+ // only react on left mouse button down
+ var leftButtonDown = event.which ? (event.which == 1) : (event.button == 1);
+ if (!leftButtonDown && !params.touchDown) {
+ return;
+ }
+
+ // get mouse position
+ params.mouseX = links.Timeline.getPageX(event);
+ params.mouseY = links.Timeline.getPageY(event);
+ params.frameLeft = links.Timeline.getAbsoluteLeft(this.dom.content);
+ params.frameTop = links.Timeline.getAbsoluteTop(this.dom.content);
+ params.previousLeft = 0;
+ params.previousOffset = 0;
+
+ params.moved = false;
+ params.start = new Date(this.start.valueOf());
+ params.end = new Date(this.end.valueOf());
+
+ params.target = links.Timeline.getTarget(event);
+ var dragLeft = (dom.items && dom.items.dragLeft) ? dom.items.dragLeft : undefined;
+ var dragRight = (dom.items && dom.items.dragRight) ? dom.items.dragRight : undefined;
+ params.itemDragLeft = (params.target === dragLeft);
+ params.itemDragRight = (params.target === dragRight);
+
+ if (params.itemDragLeft || params.itemDragRight) {
+ params.itemIndex = (this.selection && this.selection.index !== undefined) ? this.selection.index : undefined;
+ delete params.clusterIndex;
+ } else {
+ params.itemIndex = this.getItemIndex(params.target);
+ params.clusterIndex = this.getClusterIndex(params.target);
+ }
+
+ params.customTime = (params.target === dom.customTime ||
+ params.target.parentNode === dom.customTime) ?
+ this.customTime :
+ undefined;
+
+ params.addItem = (options.editable && event.ctrlKey);
+ if (params.addItem) {
+ // create a new event at the current mouse position
+ var x = params.mouseX - params.frameLeft;
+ var y = params.mouseY - params.frameTop;
+
+ var xstart = this.screenToTime(x);
+ if (options.snapEvents) {
+ this.step.snap(xstart);
+ }
+ var xend = new Date(xstart.valueOf());
+ var content = options.NEW;
+ var group = this.getGroupFromHeight(y);
+ this.addItem({
+ 'start': xstart,
+ 'end': xend,
+ 'content': content,
+ 'group': this.getGroupName(group)
+ });
+ params.itemIndex = (this.items.length - 1);
+ delete params.clusterIndex;
+ this.selectItem(params.itemIndex);
+ params.itemDragRight = true;
+ }
+
+ var item = this.items[params.itemIndex];
+ var isSelected = this.isSelected(params.itemIndex);
+ params.editItem = isSelected && this.isEditable(item);
+ if (params.editItem) {
+ params.itemStart = item.start;
+ params.itemEnd = item.end;
+ params.itemGroup = item.group;
+ params.itemLeft = item.getLeft(this); // NH Use item.getLeft here
+ params.itemRight = item.getRight(this); // NH Use item.getRight here
+ } else {
+ this.dom.frame.style.cursor = 'move';
+ }
+ if (!params.touchDown) {
+ // add event listeners to handle moving the contents
+ // we store the function onmousemove and onmouseup in the timeline, so we can
+ // remove the eventlisteners lateron in the function mouseUp()
+ var me = this;
+ if (!params.onMouseMove) {
+ params.onMouseMove = function (event) {
+me.onMouseMove(event);};
+ links.Timeline.addEventListener(document, "mousemove", params.onMouseMove);
+ }
+ if (!params.onMouseUp) {
+ params.onMouseUp = function (event) {
+me.onMouseUp(event);};
+ links.Timeline.addEventListener(document, "mouseup", params.onMouseUp);
+ }
+
+ links.Timeline.preventDefault(event);
+ }
+};
+
+
+/**
+ * Perform moving operating.
+ * This function activated from within the funcion links.Timeline.onMouseDown().
+ * @param {Event} event Well, eehh, the event
+ */
+links.Timeline.prototype.onMouseMove = function (event) {
+ event = event || window.event;
+
+ var params = this.eventParams,
+ size = this.size,
+ dom = this.dom,
+ options = this.options;
+
+ // calculate change in mouse position
+ var mouseX = links.Timeline.getPageX(event);
+ var mouseY = links.Timeline.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;
+
+ // if mouse movement is big enough, register it as a "moved" event
+ if (Math.abs(diffX) >= 1) {
+ params.moved = true;
+ }
+
+ if (params.customTime) {
+ var x = this.timeToScreen(params.customTime);
+ var xnew = x + diffX;
+ this.customTime = this.screenToTime(xnew);
+ this.repaintCustomTime();
+
+ // fire a timechange event
+ this.trigger('timechange');
+ } else if (params.editItem) {
+ var item = this.items[params.itemIndex],
+ left,
+ right;
+
+ if (params.itemDragLeft && options.timeChangeable) {
+ // move the start of the item
+ left = params.itemLeft + diffX;
+ right = params.itemRight;
+
+ item.start = this.screenToTime(left);
+ if (options.snapEvents) {
+ this.step.snap(item.start);
+ left = this.timeToScreen(item.start);
+ }
+
+ if (left > right) {
+ left = right;
+ item.start = this.screenToTime(left);
+ }
+ this.trigger('change');
+ } else if (params.itemDragRight && options.timeChangeable) {
+ // move the end of the item
+ left = params.itemLeft;
+ right = params.itemRight + diffX;
+
+ item.end = this.screenToTime(right);
+ if (options.snapEvents) {
+ this.step.snap(item.end);
+ right = this.timeToScreen(item.end);
+ }
+
+ if (right < left) {
+ right = left;
+ item.end = this.screenToTime(right);
+ }
+ this.trigger('change');
+ } else if (options.timeChangeable) {
+ // move the item
+ left = params.itemLeft + diffX;
+ item.start = this.screenToTime(left);
+ if (options.snapEvents) {
+ this.step.snap(item.start);
+ left = this.timeToScreen(item.start);
+ }
+
+ if (item.end) {
+ right = left + (params.itemRight - params.itemLeft);
+ item.end = this.screenToTime(right);
+ }
+ this.trigger('change');
+ }
+
+ item.setPosition(left, right);
+
+ var dragging = params.itemDragLeft || params.itemDragRight;
+ if (this.groups.length && !dragging) {
+ // move item from one group to another when needed
+ var y = mouseY - params.frameTop;
+ var group = this.getGroupFromHeight(y);
+ if (options.groupsChangeable && item.group !== group) {
+ // move item to the other group
+ var index = this.items.indexOf(item);
+ this.changeItem(index, {'group': this.getGroupName(group)});
+ } else {
+ this.repaintDeleteButton();
+ this.repaintDragAreas();
+ }
+ } else {
+ // TODO: does not work well in FF, forces redraw with every mouse move it seems
+ this.render(); // TODO: optimize, only redraw the items?
+ // Note: when animate==true, no redraw is needed here, its done by stackItems animation
+ }
+ } else if (options.moveable) {
+ var interval = (params.end.valueOf() - params.start.valueOf());
+ var diffMillisecs = Math.round((-diffX) / size.contentWidth * interval);
+ var newStart = new Date(params.start.valueOf() + diffMillisecs);
+ var newEnd = new Date(params.end.valueOf() + diffMillisecs);
+ this.applyRange(newStart, newEnd);
+ // if the applied range is moved due to a fixed min or max,
+ // change the diffMillisecs accordingly
+ var appliedDiff = (this.start.valueOf() - newStart.valueOf());
+ if (appliedDiff) {
+ diffMillisecs += appliedDiff;
+ }
+
+ this.recalcConversion();
+
+ // move the items by changing the left position of their frame.
+ // this is much faster than repositioning all elements individually via the
+ // repaintFrame() function (which is done once at mouseup)
+ // note that we round diffX to prevent wrong positioning on millisecond scale
+ var previousLeft = params.previousLeft || 0;
+ var currentLeft = parseFloat(dom.items.frame.style.left) || 0;
+ var previousOffset = params.previousOffset || 0;
+ var frameOffset = previousOffset + (currentLeft - previousLeft);
+ var frameLeft = -diffMillisecs / interval * size.contentWidth + frameOffset;
+
+ dom.items.frame.style.left = (frameLeft) + "px";
+
+ // read the left again from DOM (IE8- rounds the value)
+ params.previousOffset = frameOffset;
+ params.previousLeft = parseFloat(dom.items.frame.style.left) || frameLeft;
+
+ this.repaintCurrentTime();
+ this.repaintCustomTime();
+ this.repaintAxis();
+
+ // fire a rangechange event
+ this.trigger('rangechange');
+ }
+
+ links.Timeline.preventDefault(event);
+};
+
+
+/**
+ * Stop moving operating.
+ * This function activated from within the funcion links.Timeline.onMouseDown().
+ * @param {event} event The event
+ */
+links.Timeline.prototype.onMouseUp = function (event) {
+ var params = this.eventParams,
+ options = this.options;
+
+ event = event || window.event;
+
+ this.dom.frame.style.cursor = 'auto';
+
+ // remove event listeners here, important for Safari
+ if (params.onMouseMove) {
+ links.Timeline.removeEventListener(document, "mousemove", params.onMouseMove);
+ delete params.onMouseMove;
+ }
+ if (params.onMouseUp) {
+ links.Timeline.removeEventListener(document, "mouseup", params.onMouseUp);
+ delete params.onMouseUp;
+ }
+ //links.Timeline.preventDefault(event);
+
+ if (params.customTime) {
+ // fire a timechanged event
+ this.trigger('timechanged');
+ } else if (params.editItem) {
+ var item = this.items[params.itemIndex];
+
+ if (params.moved || params.addItem) {
+ this.applyChange = true;
+ this.applyAdd = true;
+
+ this.updateData(params.itemIndex, {
+ 'start': item.start,
+ 'end': item.end
+ });
+
+ // fire an add or changed event.
+ // Note that the change can be canceled from within an event listener if
+ // this listener calls the method cancelChange().
+ this.trigger(params.addItem ? 'add' : 'changed');
+
+ //retrieve item data again to include changes made to it in the triggered event handlers
+ item = this.items[params.itemIndex];
+
+ if (params.addItem) {
+ if (this.applyAdd) {
+ this.updateData(params.itemIndex, {
+ 'start': item.start,
+ 'end': item.end,
+ 'content': item.content,
+ 'group': this.getGroupName(item.group)
+ });
+ } else {
+ // undo an add
+ this.deleteItem(params.itemIndex);
+ }
+ } else {
+ if (this.applyChange) {
+ this.updateData(params.itemIndex, {
+ 'start': item.start,
+ 'end': item.end
+ });
+ } else {
+ // undo a change
+ delete this.applyChange;
+ delete this.applyAdd;
+
+ var item = this.items[params.itemIndex],
+ domItem = item.dom;
+
+ item.start = params.itemStart;
+ item.end = params.itemEnd;
+ item.group = params.itemGroup;
+ // TODO: original group should be restored too
+ item.setPosition(params.itemLeft, params.itemRight);
+
+ this.updateData(params.itemIndex, {
+ 'start': params.itemStart,
+ 'end': params.itemEnd
+ });
+ }
+ }
+
+ // prepare data for clustering, by filtering and sorting by type
+ if (this.options.cluster) {
+ this.clusterGenerator.updateData();
+ }
+
+ this.render();
+ }
+ } else {
+ if (!params.moved && !params.zoomed) {
+ // mouse did not move -> user has selected an item
+
+ if (params.target === this.dom.items.deleteButton) {
+ // delete item
+ if (this.selection && this.selection.index !== undefined) {
+ this.confirmDeleteItem(this.selection.index);
+ }
+ } else if (options.selectable) {
+ // select/unselect item
+ if (params.itemIndex != undefined) {
+ if (!this.isSelected(params.itemIndex)) {
+ this.selectItem(params.itemIndex);
+ this.trigger('select');
+ }
+ } else if (params.clusterIndex != undefined) {
+ this.selectCluster(params.clusterIndex);
+ this.trigger('select');
+ } else {
+ if (options.unselectable) {
+ this.unselectItem();
+ this.trigger('select');
+ }
+ }
+ }
+ } else {
+ // timeline is moved
+ // TODO: optimize: no need to reflow and cluster again?
+ this.render();
+
+ if ((params.moved && options.moveable) || (params.zoomed && options.zoomable) ) {
+ // fire a rangechanged event
+ this.trigger('rangechanged');
+ }
+ }
+ }
+};
+
+/**
+ * Double click event occurred for an item
+ * @param {Event} event
+ */
+links.Timeline.prototype.onDblClick = function (event) {
+ var params = this.eventParams,
+ options = this.options,
+ dom = this.dom,
+ size = this.size;
+ event = event || window.event;
+
+ if (params.itemIndex != undefined) {
+ var item = this.items[params.itemIndex];
+ if (item && this.isEditable(item)) {
+ // fire the edit event
+ this.trigger('edit');
+ }
+ } else {
+ if (options.editable) {
+ // create a new item
+
+ // get mouse position
+ params.mouseX = links.Timeline.getPageX(event);
+ params.mouseY = links.Timeline.getPageY(event);
+ var x = params.mouseX - links.Timeline.getAbsoluteLeft(dom.content);
+ var y = params.mouseY - links.Timeline.getAbsoluteTop(dom.content);
+
+ // create a new event at the current mouse position
+ var xstart = this.screenToTime(x);
+ if (options.snapEvents) {
+ this.step.snap(xstart);
+ }
+
+ var content = options.NEW;
+ var group = this.getGroupFromHeight(y); // (group may be undefined)
+ var preventRender = true;
+ this.addItem({
+ 'start': xstart,
+ 'content': content,
+ 'group': this.getGroupName(group)
+ }, preventRender);
+ params.itemIndex = (this.items.length - 1);
+ this.selectItem(params.itemIndex);
+
+ this.applyAdd = true;
+
+ // fire an add event.
+ // Note that the change can be canceled from within an event listener if
+ // this listener calls the method cancelAdd().
+ this.trigger('add');
+
+ if (this.applyAdd) {
+ // render and select the item
+ this.render({animate: false});
+ this.selectItem(params.itemIndex);
+ } else {
+ // undo an add
+ this.deleteItem(params.itemIndex);
+ }
+ }
+ }
+
+ links.Timeline.preventDefault(event);
+};
+
+
+/**
+ * Event handler for mouse wheel event, used to zoom the timeline
+ * Code from http://adomas.org/javascript-mouse-wheel/
+ * @param {Event} event The event
+ */
+links.Timeline.prototype.onMouseWheel = function (event) {
+ if (!this.options.zoomable) {
+ return; }
+
+ if (!event) { /* For IE. */
+ 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) {
+ // TODO: on FireFox, the window is not redrawn within repeated scroll-events
+ // -> use a delayed redraw? Make a zoom queue?
+
+ var timeline = this;
+ var zoom = function () {
+ // perform the zoom action. Delta is normally 1 or -1
+ var zoomFactor = delta / 5.0;
+ var frameLeft = links.Timeline.getAbsoluteLeft(timeline.dom.content);
+ var mouseX = links.Timeline.getPageX(event);
+ var zoomAroundDate =
+ (mouseX != undefined && frameLeft != undefined) ?
+ timeline.screenToTime(mouseX - frameLeft) :
+ undefined;
+
+ timeline.zoom(zoomFactor, zoomAroundDate);
+
+ // fire a rangechange and a rangechanged event
+ timeline.trigger("rangechange");
+ timeline.trigger("rangechanged");
+ };
+
+ var scroll = function () {
+ // Scroll the timeline
+ timeline.move(delta * -0.2);
+ timeline.trigger("rangechange");
+ timeline.trigger("rangechanged");
+ };
+
+ if (event.shiftKey) {
+ scroll();
+ } else {
+ zoom();
+ }
+ }
+
+ // Prevent default actions caused by mouse wheel.
+ // That might be ugly, but we handle scrolls somehow
+ // anyway, so don't bother here...
+ links.Timeline.preventDefault(event);
+};
+
+
+/**
+ * Zoom the timeline 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 {Date} zoomAroundDate Date around which will be zoomed. Optional
+ */
+links.Timeline.prototype.zoom = function (zoomFactor, zoomAroundDate) {
+ // if zoomAroundDate is not provided, take it half between start Date and end Date
+ if (zoomAroundDate == undefined) {
+ zoomAroundDate = new Date((this.start.valueOf() + this.end.valueOf()) / 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 Date and end Date relative to the zoomAroundDate
+ var startDiff = (this.start.valueOf() - zoomAroundDate);
+ var endDiff = (this.end.valueOf() - zoomAroundDate);
+
+ // calculate new dates
+ var newStart = new Date(this.start.valueOf() - startDiff * zoomFactor);
+ var newEnd = new Date(this.end.valueOf() - endDiff * zoomFactor);
+
+ // only zoom in when interval is larger than minimum interval (to prevent
+ // sliding to left/right when having reached the minimum zoom level)
+ var interval = (newEnd.valueOf() - newStart.valueOf());
+ var zoomMin = Number(this.options.zoomMin) || 10;
+ if (zoomMin < 10) {
+ zoomMin = 10;
+ }
+ if (interval >= zoomMin) {
+ this.applyRange(newStart, newEnd, zoomAroundDate);
+ this.render({
+ animate: this.options.animate && this.options.animateZoom
+ });
+ }
+};
+
+/**
+ * Move the timeline the given movefactor to the left or right. Start and end
+ * date will be adjusted, and the timeline will be redrawn.
+ * For example, try moveFactor = 0.1 or -0.1
+ * @param {Number} moveFactor Moving amount. Positive value will move right,
+ * negative value will move left
+ */
+links.Timeline.prototype.move = function (moveFactor) {
+ // zoom start Date and end Date relative to the zoomAroundDate
+ var diff = (this.end.valueOf() - this.start.valueOf());
+
+ // apply new dates
+ var newStart = new Date(this.start.valueOf() + diff * moveFactor);
+ var newEnd = new Date(this.end.valueOf() + diff * moveFactor);
+ this.applyRange(newStart, newEnd);
+
+ this.render(); // TODO: optimize, no need to reflow, only to recalc conversion and repaint
+};
+
+/**
+ * Apply a visible range. The range is limited to feasible maximum and minimum
+ * range.
+ * @param {Date} start
+ * @param {Date} end
+ * @param {Date} zoomAroundDate Optional. Date around which will be zoomed.
+ */
+links.Timeline.prototype.applyRange = function (start, end, zoomAroundDate) {
+ // calculate new start and end value
+ var startValue = start.valueOf(); // number
+ var endValue = end.valueOf(); // number
+ var interval = (endValue - startValue);
+
+ // determine maximum and minimum interval
+ var options = this.options;
+ var year = 1000 * 60 * 60 * 24 * 365;
+ var zoomMin = Number(options.zoomMin) || 10;
+ if (zoomMin < 10) {
+ zoomMin = 10;
+ }
+ var zoomMax = Number(options.zoomMax) || 10000 * year;
+ if (zoomMax > 10000 * year) {
+ zoomMax = 10000 * year;
+ }
+ if (zoomMax < zoomMin) {
+ zoomMax = zoomMin;
+ }
+
+ // determine min and max date value
+ var min = options.min ? options.min.valueOf() : undefined; // number
+ var max = options.max ? options.max.valueOf() : undefined; // number
+ if (min != undefined && max != undefined) {
+ if (min >= max) {
+ // empty range
+ var day = 1000 * 60 * 60 * 24;
+ max = min + day;
+ }
+ if (zoomMax > (max - min)) {
+ zoomMax = (max - min);
+ }
+ if (zoomMin > (max - min)) {
+ zoomMin = (max - min);
+ }
+ }
+
+ // prevent empty interval
+ if (startValue >= endValue) {
+ endValue += 1000 * 60 * 60 * 24;
+ }
+
+ // prevent too small scale
+ // TODO: IE has problems with milliseconds
+ if (interval < zoomMin) {
+ var diff = (zoomMin - interval);
+ var f = zoomAroundDate ? (zoomAroundDate.valueOf() - startValue) / interval : 0.5;
+ startValue -= Math.round(diff * f);
+ endValue += Math.round(diff * (1 - f));
+ }
+
+ // prevent too large scale
+ if (interval > zoomMax) {
+ var diff = (interval - zoomMax);
+ var f = zoomAroundDate ? (zoomAroundDate.valueOf() - startValue) / interval : 0.5;
+ startValue += Math.round(diff * f);
+ endValue -= Math.round(diff * (1 - f));
+ }
+
+ // prevent to small start date
+ if (min != undefined) {
+ var diff = (startValue - min);
+ if (diff < 0) {
+ startValue -= diff;
+ endValue -= diff;
+ }
+ }
+
+ // prevent to large end date
+ if (max != undefined) {
+ var diff = (max - endValue);
+ if (diff < 0) {
+ startValue += diff;
+ endValue += diff;
+ }
+ }
+
+ // apply new dates
+ this.start = new Date(startValue);
+ this.end = new Date(endValue);
+};
+
+/**
+ * Delete an item after a confirmation.
+ * The deletion can be cancelled by executing .cancelDelete() during the
+ * triggered event 'delete'.
+ * @param {int} index Index of the item to be deleted
+ */
+links.Timeline.prototype.confirmDeleteItem = function (index) {
+ this.applyDelete = true;
+
+ // select the event to be deleted
+ if (!this.isSelected(index)) {
+ this.selectItem(index);
+ }
+
+ // fire a delete event trigger.
+ // Note that the delete event can be canceled from within an event listener if
+ // this listener calls the method cancelChange().
+ this.trigger('delete');
+
+ if (this.applyDelete) {
+ this.deleteItem(index);
+ }
+
+ delete this.applyDelete;
+};
+
+/**
+ * Delete an item
+ * @param {int} index Index of the item to be deleted
+ * @param {boolean} [preventRender=false] Do not re-render timeline if true
+ * (optimization for multiple delete)
+ */
+links.Timeline.prototype.deleteItem = function (index, preventRender) {
+ if (index >= this.items.length) {
+ throw "Cannot delete row, index out of range";
+ }
+
+ if (this.selection && this.selection.index !== undefined) {
+ // adjust the selection
+ if (this.selection.index == index) {
+ // item to be deleted is selected
+ this.unselectItem();
+ } else if (this.selection.index > index) {
+ // update selection index
+ this.selection.index--;
+ }
+ }
+
+ // actually delete the item and remove it from the DOM
+ var item = this.items.splice(index, 1)[0];
+ this.renderQueue.hide.push(item);
+
+ // delete the row in the original data table
+ if (this.data) {
+ if (google && google.visualization &&
+ this.data instanceof google.visualization.DataTable) {
+ this.data.removeRow(index);
+ } else if (links.Timeline.isArray(this.data)) {
+ this.data.splice(index, 1);
+ } else {
+ throw "Cannot delete row from data, unknown data type";
+ }
+ }
+
+ // prepare data for clustering, by filtering and sorting by type
+ if (this.options.cluster) {
+ this.clusterGenerator.updateData();
+ }
+
+ if (!preventRender) {
+ this.render();
+ }
+};
+
+
+/**
+ * Delete all items
+ */
+links.Timeline.prototype.deleteAllItems = function () {
+ this.unselectItem();
+
+ // delete the loaded items
+ this.clearItems();
+
+ // delete the groups
+ this.deleteGroups();
+
+ // empty original data table
+ if (this.data) {
+ if (google && google.visualization &&
+ this.data instanceof google.visualization.DataTable) {
+ this.data.removeRows(0, this.data.getNumberOfRows());
+ } else if (links.Timeline.isArray(this.data)) {
+ this.data.splice(0, this.data.length);
+ } else {
+ throw "Cannot delete row from data, unknown data type";
+ }
+ }
+
+ // prepare data for clustering, by filtering and sorting by type
+ if (this.options.cluster) {
+ this.clusterGenerator.updateData();
+ }
+
+ this.render();
+};
+
+
+/**
+ * Find the group from a given height in the timeline
+ * @param {Number} height Height in the timeline
+ * @return {Object | undefined} group The group object, or undefined if out
+ * of range
+ */
+links.Timeline.prototype.getGroupFromHeight = function (height) {
+ var i,
+ group,
+ groups = this.groups;
+
+ if (groups.length) {
+ if (this.options.axisOnTop) {
+ for (i = groups.length - 1; i >= 0; i--) {
+ group = groups[i];
+ if (height > group.top) {
+ return group;
+ }
+ }
+ } else {
+ for (i = 0; i < groups.length; i++) {
+ group = groups[i];
+ if (height > group.top) {
+ return group;
+ }
+ }
+ }
+
+ return group; // return the last group
+ }
+
+ return undefined;
+};
+
+/**
+ * @constructor links.Timeline.Item
+ * @param {Object} data Object containing parameters start, end
+ * content, group, type, editable.
+ * @param {Object} [options] Options to set initial property values
+ * {Number} top
+ * {Number} left
+ * {Number} width
+ * {Number} height
+ */
+links.Timeline.Item = function (data, options) {
+ if (data) {
+ /* TODO: use parseJSONDate as soon as it is tested and working (in two directions)
+ this.start = links.Timeline.parseJSONDate(data.start);
+ this.end = links.Timeline.parseJSONDate(data.end);
+ */
+ this.start = data.start;
+ this.end = data.end;
+ this.content = data.content;
+ this.className = data.className;
+ this.editable = data.editable;
+ this.group = data.group;
+ this.type = data.type;
+ }
+ this.top = 0;
+ this.left = 0;
+ this.width = 0;
+ this.height = 0;
+ this.lineWidth = 0;
+ this.dotWidth = 0;
+ this.dotHeight = 0;
+
+ this.rendered = false; // true when the item is draw in the Timeline DOM
+
+ if (options) {
+ // override the default properties
+ for (var option in options) {
+ if (options.hasOwnProperty(option)) {
+ this[option] = options[option];
+ }
+ }
+ }
+
+};
+
+
+
+/**
+ * Reflow the Item: retrieve its actual size from the DOM
+ * @return {boolean} resized returns true if the axis is resized
+ */
+links.Timeline.Item.prototype.reflow = function () {
+ // Should be implemented by sub-prototype
+ return false;
+};
+
+/**
+ * Append all image urls present in the items DOM to the provided array
+ * @param {String[]} imageUrls
+ */
+links.Timeline.Item.prototype.getImageUrls = function (imageUrls) {
+ if (this.dom) {
+ links.imageloader.filterImageUrls(this.dom, imageUrls);
+ }
+};
+
+/**
+ * Select the item
+ */
+links.Timeline.Item.prototype.select = function () {
+ // Should be implemented by sub-prototype
+};
+
+/**
+ * Unselect the item
+ */
+links.Timeline.Item.prototype.unselect = function () {
+ // Should be implemented by sub-prototype
+};
+
+/**
+ * Creates the DOM for the item, depending on its type
+ * @return {Element | undefined}
+ */
+links.Timeline.Item.prototype.createDOM = function () {
+ // Should be implemented by sub-prototype
+};
+
+/**
+ * Append the items DOM to the given HTML container. If items DOM does not yet
+ * exist, it will be created first.
+ * @param {Element} container
+ */
+links.Timeline.Item.prototype.showDOM = function (container) {
+ // Should be implemented by sub-prototype
+};
+
+/**
+ * Remove the items DOM from the current HTML container
+ * @param {Element} container
+ */
+links.Timeline.Item.prototype.hideDOM = function (container) {
+ // Should be implemented by sub-prototype
+};
+
+/**
+ * Update the DOM of the item. This will update the content and the classes
+ * of the item
+ */
+links.Timeline.Item.prototype.updateDOM = function () {
+ // Should be implemented by sub-prototype
+};
+
+/**
+ * Reposition the item, recalculate its left, top, and width, using the current
+ * range of the timeline and the timeline options.
+ * @param {links.Timeline} timeline
+ */
+links.Timeline.Item.prototype.updatePosition = function (timeline) {
+ // Should be implemented by sub-prototype
+};
+
+/**
+ * Check if the item is drawn in the timeline (i.e. the DOM of the item is
+ * attached to the frame. You may also just request the parameter item.rendered
+ * @return {boolean} rendered
+ */
+links.Timeline.Item.prototype.isRendered = function () {
+ return this.rendered;
+};
+
+/**
+ * Check if the item is located in the visible area of the timeline, and
+ * not part of a cluster
+ * @param {Date} start
+ * @param {Date} end
+ * @return {boolean} visible
+ */
+links.Timeline.Item.prototype.isVisible = function (start, end) {
+ // Should be implemented by sub-prototype
+ return false;
+};
+
+/**
+ * Reposition the item
+ * @param {Number} left
+ * @param {Number} right
+ */
+links.Timeline.Item.prototype.setPosition = function (left, right) {
+ // Should be implemented by sub-prototype
+};
+
+/**
+ * Calculate the left position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} left
+ */
+links.Timeline.Item.prototype.getLeft = function (timeline) {
+ // Should be implemented by sub-prototype
+ return 0;
+};
+
+/**
+ * Calculate the right position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} right
+ */
+links.Timeline.Item.prototype.getRight = function (timeline) {
+ // Should be implemented by sub-prototype
+ return 0;
+};
+
+/**
+ * Calculate the width of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} width
+ */
+links.Timeline.Item.prototype.getWidth = function (timeline) {
+ // Should be implemented by sub-prototype
+ return this.width || 0; // last rendered width
+};
+
+
+/**
+ * @constructor links.Timeline.ItemBox
+ * @extends links.Timeline.Item
+ * @param {Object} data Object containing parameters start, end
+ * content, group, type, className, editable.
+ * @param {Object} [options] Options to set initial property values
+ * {Number} top
+ * {Number} left
+ * {Number} width
+ * {Number} height
+ */
+links.Timeline.ItemBox = function (data, options) {
+ links.Timeline.Item.call(this, data, options);
+};
+
+links.Timeline.ItemBox.prototype = new links.Timeline.Item();
+
+/**
+ * Reflow the Item: retrieve its actual size from the DOM
+ * @return {boolean} resized returns true if the axis is resized
+ * @override
+ */
+links.Timeline.ItemBox.prototype.reflow = function () {
+ var dom = this.dom,
+ dotHeight = dom.dot.offsetHeight,
+ dotWidth = dom.dot.offsetWidth,
+ lineWidth = dom.line.offsetWidth,
+ resized = (
+ (this.dotHeight != dotHeight) ||
+ (this.dotWidth != dotWidth) ||
+ (this.lineWidth != lineWidth)
+ );
+
+ this.dotHeight = dotHeight;
+ this.dotWidth = dotWidth;
+ this.lineWidth = lineWidth;
+
+ return resized;
+};
+
+/**
+ * Select the item
+ * @override
+ */
+links.Timeline.ItemBox.prototype.select = function () {
+ var dom = this.dom;
+ links.Timeline.addClassName(dom, 'timeline-event-selected ui-state-active');
+ links.Timeline.addClassName(dom.line, 'timeline-event-selected ui-state-active');
+ links.Timeline.addClassName(dom.dot, 'timeline-event-selected ui-state-active');
+};
+
+/**
+ * Unselect the item
+ * @override
+ */
+links.Timeline.ItemBox.prototype.unselect = function () {
+ var dom = this.dom;
+ links.Timeline.removeClassName(dom, 'timeline-event-selected ui-state-active');
+ links.Timeline.removeClassName(dom.line, 'timeline-event-selected ui-state-active');
+ links.Timeline.removeClassName(dom.dot, 'timeline-event-selected ui-state-active');
+};
+
+/**
+ * Creates the DOM for the item, depending on its type
+ * @return {Element | undefined}
+ * @override
+ */
+links.Timeline.ItemBox.prototype.createDOM = function () {
+ // background box
+ var divBox = document.createElement("DIV");
+ divBox.style.position = "absolute";
+ divBox.style.left = this.left + "px";
+ divBox.style.top = this.top + "px";
+
+ // contents box (inside the background box). used for making margins
+ var divContent = document.createElement("DIV");
+ divContent.className = "timeline-event-content";
+ divContent.innerHTML = this.content;
+ divBox.appendChild(divContent);
+
+ // line to axis
+ var divLine = document.createElement("DIV");
+ divLine.style.position = "absolute";
+ divLine.style.width = "0px";
+ // important: the vertical line is added at the front of the list of elements,
+ // so it will be drawn behind all boxes and ranges
+ divBox.line = divLine;
+
+ // dot on axis
+ var divDot = document.createElement("DIV");
+ divDot.style.position = "absolute";
+ divDot.style.width = "0px";
+ divDot.style.height = "0px";
+ divBox.dot = divDot;
+
+ this.dom = divBox;
+ this.updateDOM();
+
+ return divBox;
+};
+
+/**
+ * Append the items DOM to the given HTML container. If items DOM does not yet
+ * exist, it will be created first.
+ * @param {Element} container
+ * @override
+ */
+links.Timeline.ItemBox.prototype.showDOM = function (container) {
+ var dom = this.dom;
+ if (!dom) {
+ dom = this.createDOM();
+ }
+
+ if (dom.parentNode != container) {
+ if (dom.parentNode) {
+ // container is changed. remove from old container
+ this.hideDOM();
+ }
+
+ // append to this container
+ container.appendChild(dom);
+ container.insertBefore(dom.line, container.firstChild);
+ // Note: line must be added in front of the this,
+ // such that it stays below all this
+ container.appendChild(dom.dot);
+ this.rendered = true;
+ }
+};
+
+/**
+ * Remove the items DOM from the current HTML container, but keep the DOM in
+ * memory
+ * @override
+ */
+links.Timeline.ItemBox.prototype.hideDOM = function () {
+ var dom = this.dom;
+ if (dom) {
+ if (dom.parentNode) {
+ dom.parentNode.removeChild(dom);
+ }
+ if (dom.line && dom.line.parentNode) {
+ dom.line.parentNode.removeChild(dom.line);
+ }
+ if (dom.dot && dom.dot.parentNode) {
+ dom.dot.parentNode.removeChild(dom.dot);
+ }
+ this.rendered = false;
+ }
+};
+
+/**
+ * Update the DOM of the item. This will update the content and the classes
+ * of the item
+ * @override
+ */
+links.Timeline.ItemBox.prototype.updateDOM = function () {
+ var divBox = this.dom;
+ if (divBox) {
+ var divLine = divBox.line;
+ var divDot = divBox.dot;
+
+ // update contents
+ divBox.firstChild.innerHTML = this.content;
+
+ // update class
+ divBox.className = "timeline-event timeline-event-box ui-widget ui-state-default";
+ divLine.className = "timeline-event timeline-event-line ui-widget ui-state-default";
+ divDot.className = "timeline-event timeline-event-dot ui-widget ui-state-default";
+
+ if (this.isCluster) {
+ links.Timeline.addClassName(divBox, 'timeline-event-cluster ui-widget-header');
+ links.Timeline.addClassName(divLine, 'timeline-event-cluster ui-widget-header');
+ links.Timeline.addClassName(divDot, 'timeline-event-cluster ui-widget-header');
+ }
+
+ // add item specific class name when provided
+ if (this.className) {
+ links.Timeline.addClassName(divBox, this.className);
+ links.Timeline.addClassName(divLine, this.className);
+ links.Timeline.addClassName(divDot, this.className);
+ }
+
+ // TODO: apply selected className?
+ }
+};
+
+/**
+ * Reposition the item, recalculate its left, top, and width, using the current
+ * range of the timeline and the timeline options.
+ * @param {links.Timeline} timeline
+ * @override
+ */
+links.Timeline.ItemBox.prototype.updatePosition = function (timeline) {
+ var dom = this.dom;
+ if (dom) {
+ var left = timeline.timeToScreen(this.start),
+ axisOnTop = timeline.options.axisOnTop,
+ axisTop = timeline.size.axis.top,
+ axisHeight = timeline.size.axis.height,
+ boxAlign = (timeline.options.box && timeline.options.box.align) ?
+ timeline.options.box.align : undefined;
+
+ dom.style.top = this.top + "px";
+ if (boxAlign == 'right') {
+ dom.style.left = (left - this.width) + "px";
+ } else if (boxAlign == 'left') {
+ dom.style.left = (left) + "px";
+ } else { // default or 'center'
+ dom.style.left = (left - this.width/2) + "px";
+ }
+
+ var line = dom.line;
+ var dot = dom.dot;
+ line.style.left = (left - this.lineWidth/2) + "px";
+ dot.style.left = (left - this.dotWidth/2) + "px";
+ if (axisOnTop) {
+ line.style.top = axisHeight + "px";
+ line.style.height = Math.max(this.top - axisHeight, 0) + "px";
+ dot.style.top = (axisHeight - this.dotHeight/2) + "px";
+ } else {
+ line.style.top = (this.top + this.height) + "px";
+ line.style.height = Math.max(axisTop - this.top - this.height, 0) + "px";
+ dot.style.top = (axisTop - this.dotHeight/2) + "px";
+ }
+ }
+};
+
+/**
+ * Check if the item is visible in the timeline, and not part of a cluster
+ * @param {Date} start
+ * @param {Date} end
+ * @return {Boolean} visible
+ * @override
+ */
+links.Timeline.ItemBox.prototype.isVisible = function (start, end) {
+ if (this.cluster) {
+ return false;
+ }
+
+ return (this.start > start) && (this.start < end);
+};
+
+/**
+ * Reposition the item
+ * @param {Number} left
+ * @param {Number} right
+ * @override
+ */
+links.Timeline.ItemBox.prototype.setPosition = function (left, right) {
+ var dom = this.dom;
+
+ dom.style.left = (left - this.width / 2) + "px";
+ dom.line.style.left = (left - this.lineWidth / 2) + "px";
+ dom.dot.style.left = (left - this.dotWidth / 2) + "px";
+
+ if (this.group) {
+ this.top = this.group.top;
+ dom.style.top = this.top + 'px';
+ }
+};
+
+/**
+ * Calculate the left position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} left
+ * @override
+ */
+links.Timeline.ItemBox.prototype.getLeft = function (timeline) {
+ var boxAlign = (timeline.options.box && timeline.options.box.align) ?
+ timeline.options.box.align : undefined;
+
+ var left = timeline.timeToScreen(this.start);
+ if (boxAlign == 'right') {
+ left = left - width;
+ } else { // default or 'center'
+ left = (left - this.width / 2);
+ }
+
+ return left;
+};
+
+/**
+ * Calculate the right position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} right
+ * @override
+ */
+links.Timeline.ItemBox.prototype.getRight = function (timeline) {
+ var boxAlign = (timeline.options.box && timeline.options.box.align) ?
+ timeline.options.box.align : undefined;
+
+ var left = timeline.timeToScreen(this.start);
+ var right;
+ if (boxAlign == 'right') {
+ right = left;
+ } else if (boxAlign == 'left') {
+ right = (left + this.width);
+ } else { // default or 'center'
+ right = (left + this.width / 2);
+ }
+
+ return right;
+};
+
+/**
+ * @constructor links.Timeline.ItemRange
+ * @extends links.Timeline.Item
+ * @param {Object} data Object containing parameters start, end
+ * content, group, type, className, editable.
+ * @param {Object} [options] Options to set initial property values
+ * {Number} top
+ * {Number} left
+ * {Number} width
+ * {Number} height
+ */
+links.Timeline.ItemRange = function (data, options) {
+ links.Timeline.Item.call(this, data, options);
+};
+
+links.Timeline.ItemRange.prototype = new links.Timeline.Item();
+
+/**
+ * Select the item
+ * @override
+ */
+links.Timeline.ItemRange.prototype.select = function () {
+ var dom = this.dom;
+ links.Timeline.addClassName(dom, 'timeline-event-selected ui-state-active');
+};
+
+/**
+ * Unselect the item
+ * @override
+ */
+links.Timeline.ItemRange.prototype.unselect = function () {
+ var dom = this.dom;
+ links.Timeline.removeClassName(dom, 'timeline-event-selected ui-state-active');
+};
+
+/**
+ * Creates the DOM for the item, depending on its type
+ * @return {Element | undefined}
+ * @override
+ */
+links.Timeline.ItemRange.prototype.createDOM = function () {
+ // background box
+ var divBox = document.createElement("DIV");
+ divBox.style.position = "absolute";
+
+ // contents box
+ var divContent = document.createElement("DIV");
+ divContent.className = "timeline-event-content";
+ divBox.appendChild(divContent);
+
+ this.dom = divBox;
+ this.updateDOM();
+
+ return divBox;
+};
+
+/**
+ * Append the items DOM to the given HTML container. If items DOM does not yet
+ * exist, it will be created first.
+ * @param {Element} container
+ * @override
+ */
+links.Timeline.ItemRange.prototype.showDOM = function (container) {
+ var dom = this.dom;
+ if (!dom) {
+ dom = this.createDOM();
+ }
+
+ if (dom.parentNode != container) {
+ if (dom.parentNode) {
+ // container changed. remove the item from the old container
+ this.hideDOM();
+ }
+
+ // append to the new container
+ container.appendChild(dom);
+ this.rendered = true;
+ }
+};
+
+/**
+ * Remove the items DOM from the current HTML container
+ * The DOM will be kept in memory
+ * @override
+ */
+links.Timeline.ItemRange.prototype.hideDOM = function () {
+ var dom = this.dom;
+ if (dom) {
+ if (dom.parentNode) {
+ dom.parentNode.removeChild(dom);
+ }
+ this.rendered = false;
+ }
+};
+
+/**
+ * Update the DOM of the item. This will update the content and the classes
+ * of the item
+ * @override
+ */
+links.Timeline.ItemRange.prototype.updateDOM = function () {
+ var divBox = this.dom;
+ if (divBox) {
+ // update contents
+ divBox.firstChild.innerHTML = this.content;
+
+ // update class
+ divBox.className = "timeline-event timeline-event-range ui-widget ui-state-default";
+
+ if (this.isCluster) {
+ links.Timeline.addClassName(divBox, 'timeline-event-cluster ui-widget-header');
+ }
+
+ // add item specific class name when provided
+ if (this.className) {
+ links.Timeline.addClassName(divBox, this.className);
+ }
+
+ // TODO: apply selected className?
+ }
+};
+
+/**
+ * Reposition the item, recalculate its left, top, and width, using the current
+ * range of the timeline and the timeline options. *
+ * @param {links.Timeline} timeline
+ * @override
+ */
+links.Timeline.ItemRange.prototype.updatePosition = function (timeline) {
+ var dom = this.dom;
+ if (dom) {
+ var contentWidth = timeline.size.contentWidth,
+ left = timeline.timeToScreen(this.start),
+ right = timeline.timeToScreen(this.end);
+
+ // limit the width of the this, as browsers cannot draw very wide divs
+ if (left < -contentWidth) {
+ left = -contentWidth;
+ }
+ if (right > 2 * contentWidth) {
+ right = 2 * contentWidth;
+ }
+
+ dom.style.top = this.top + "px";
+ dom.style.left = left + "px";
+ //dom.style.width = Math.max(right - left - 2 * this.borderWidth, 1) + "px"; // TODO: borderWidth
+ dom.style.width = Math.max(right - left, 1) + "px";
+ }
+};
+
+/**
+ * Check if the item is visible in the timeline, and not part of a cluster
+ * @param {Number} start
+ * @param {Number} end
+ * @return {boolean} visible
+ * @override
+ */
+links.Timeline.ItemRange.prototype.isVisible = function (start, end) {
+ if (this.cluster) {
+ return false;
+ }
+
+ return (this.end > start)
+ && (this.start < end);
+};
+
+/**
+ * Reposition the item
+ * @param {Number} left
+ * @param {Number} right
+ * @override
+ */
+links.Timeline.ItemRange.prototype.setPosition = function (left, right) {
+ var dom = this.dom;
+
+ dom.style.left = left + 'px';
+ dom.style.width = (right - left) + 'px';
+
+ if (this.group) {
+ this.top = this.group.top;
+ dom.style.top = this.top + 'px';
+ }
+};
+
+/**
+ * Calculate the left position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} left
+ * @override
+ */
+links.Timeline.ItemRange.prototype.getLeft = function (timeline) {
+ return timeline.timeToScreen(this.start);
+};
+
+/**
+ * Calculate the right position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} right
+ * @override
+ */
+links.Timeline.ItemRange.prototype.getRight = function (timeline) {
+ return timeline.timeToScreen(this.end);
+};
+
+/**
+ * Calculate the width of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} width
+ * @override
+ */
+links.Timeline.ItemRange.prototype.getWidth = function (timeline) {
+ return timeline.timeToScreen(this.end) - timeline.timeToScreen(this.start);
+};
+
+/**
+ * @constructor links.Timeline.ItemFloatingRange
+ * @extends links.Timeline.Item
+ * @param {Object} data Object containing parameters start, end
+ * content, group, type, className, editable.
+ * @param {Object} [options] Options to set initial property values
+ * {Number} top
+ * {Number} left
+ * {Number} width
+ * {Number} height
+ */
+links.Timeline.ItemFloatingRange = function (data, options) {
+ links.Timeline.Item.call(this, data, options);
+};
+
+links.Timeline.ItemFloatingRange.prototype = new links.Timeline.Item();
+
+/**
+ * Select the item
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.select = function () {
+ var dom = this.dom;
+ links.Timeline.addClassName(dom, 'timeline-event-selected ui-state-active');
+};
+
+/**
+ * Unselect the item
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.unselect = function () {
+ var dom = this.dom;
+ links.Timeline.removeClassName(dom, 'timeline-event-selected ui-state-active');
+};
+
+/**
+ * Creates the DOM for the item, depending on its type
+ * @return {Element | undefined}
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.createDOM = function () {
+ // background box
+ var divBox = document.createElement("DIV");
+ divBox.style.position = "absolute";
+
+ // contents box
+ var divContent = document.createElement("DIV");
+ divContent.className = "timeline-event-content";
+ divBox.appendChild(divContent);
+
+ this.dom = divBox;
+ this.updateDOM();
+
+ return divBox;
+};
+
+/**
+ * Append the items DOM to the given HTML container. If items DOM does not yet
+ * exist, it will be created first.
+ * @param {Element} container
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.showDOM = function (container) {
+ var dom = this.dom;
+ if (!dom) {
+ dom = this.createDOM();
+ }
+
+ if (dom.parentNode != container) {
+ if (dom.parentNode) {
+ // container changed. remove the item from the old container
+ this.hideDOM();
+ }
+
+ // append to the new container
+ container.appendChild(dom);
+ this.rendered = true;
+ }
+};
+
+/**
+ * Remove the items DOM from the current HTML container
+ * The DOM will be kept in memory
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.hideDOM = function () {
+ var dom = this.dom;
+ if (dom) {
+ if (dom.parentNode) {
+ dom.parentNode.removeChild(dom);
+ }
+ this.rendered = false;
+ }
+};
+
+/**
+ * Update the DOM of the item. This will update the content and the classes
+ * of the item
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.updateDOM = function () {
+ var divBox = this.dom;
+ if (divBox) {
+ // update contents
+ divBox.firstChild.innerHTML = this.content;
+
+ // update class
+ divBox.className = "timeline-event timeline-event-range ui-widget ui-state-default";
+
+ if (this.isCluster) {
+ links.Timeline.addClassName(divBox, 'timeline-event-cluster ui-widget-header');
+ }
+
+ // add item specific class name when provided
+ if (this.className) {
+ links.Timeline.addClassName(divBox, this.className);
+ }
+
+ // TODO: apply selected className?
+ }
+};
+
+/**
+ * Reposition the item, recalculate its left, top, and width, using the current
+ * range of the timeline and the timeline options. *
+ * @param {links.Timeline} timeline
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.updatePosition = function (timeline) {
+ var dom = this.dom;
+ if (dom) {
+ var contentWidth = timeline.size.contentWidth,
+ left = this.getLeft(timeline), // NH use getLeft
+ right = this.getRight(timeline); // NH use getRight;
+
+ // limit the width of the this, as browsers cannot draw very wide divs
+ if (left < -contentWidth) {
+ left = -contentWidth;
+ }
+ if (right > 2 * contentWidth) {
+ right = 2 * contentWidth;
+ }
+
+ dom.style.top = this.top + "px";
+ dom.style.left = left + "px";
+ //dom.style.width = Math.max(right - left - 2 * this.borderWidth, 1) + "px"; // TODO: borderWidth
+ dom.style.width = Math.max(right - left, 1) + "px";
+ }
+};
+
+/**
+ * Check if the item is visible in the timeline, and not part of a cluster
+ * @param {Number} start
+ * @param {Number} end
+ * @return {boolean} visible
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.isVisible = function (start, end) {
+ if (this.cluster) {
+ return false;
+ }
+
+ // NH check for no end value
+ if (this.end && this.start) {
+ return (this.end > start)
+ && (this.start < end);
+ } else if (this.start) {
+ return (this.start < end);
+ } else if (this.end) {
+ return (this.end > start);
+ } else {
+return true;}
+};
+
+/**
+ * Reposition the item
+ * @param {Number} left
+ * @param {Number} right
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.setPosition = function (left, right) {
+ var dom = this.dom;
+
+ dom.style.left = left + 'px';
+ dom.style.width = (right - left) + 'px';
+
+ if (this.group) {
+ this.top = this.group.top;
+ dom.style.top = this.top + 'px';
+ }
+};
+
+/**
+ * Calculate the left position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} left
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.getLeft = function (timeline) {
+ // NH check for no start value
+ if (this.start) {
+ return timeline.timeToScreen(this.start);
+ } else {
+ return 0;
+ }
+};
+
+/**
+ * Calculate the right position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} right
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.getRight = function (timeline) {
+ // NH check for no end value
+ if (this.end) {
+ return timeline.timeToScreen(this.end);
+ } else {
+ return timeline.size.contentWidth;
+ }
+};
+
+/**
+ * Calculate the width of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} width
+ * @override
+ */
+links.Timeline.ItemFloatingRange.prototype.getWidth = function (timeline) {
+ return this.getRight(timeline) - this.getLeft(timeline);
+};
+
+/**
+ * @constructor links.Timeline.ItemDot
+ * @extends links.Timeline.Item
+ * @param {Object} data Object containing parameters start, end
+ * content, group, type, className, editable.
+ * @param {Object} [options] Options to set initial property values
+ * {Number} top
+ * {Number} left
+ * {Number} width
+ * {Number} height
+ */
+links.Timeline.ItemDot = function (data, options) {
+ links.Timeline.Item.call(this, data, options);
+};
+
+links.Timeline.ItemDot.prototype = new links.Timeline.Item();
+
+/**
+ * Reflow the Item: retrieve its actual size from the DOM
+ * @return {boolean} resized returns true if the axis is resized
+ * @override
+ */
+links.Timeline.ItemDot.prototype.reflow = function () {
+ var dom = this.dom,
+ dotHeight = dom.dot.offsetHeight,
+ dotWidth = dom.dot.offsetWidth,
+ contentHeight = dom.content.offsetHeight,
+ resized = (
+ (this.dotHeight != dotHeight) ||
+ (this.dotWidth != dotWidth) ||
+ (this.contentHeight != contentHeight)
+ );
+
+ this.dotHeight = dotHeight;
+ this.dotWidth = dotWidth;
+ this.contentHeight = contentHeight;
+
+ return resized;
+};
+
+/**
+ * Select the item
+ * @override
+ */
+links.Timeline.ItemDot.prototype.select = function () {
+ var dom = this.dom;
+ links.Timeline.addClassName(dom, 'timeline-event-selected ui-state-active');
+};
+
+/**
+ * Unselect the item
+ * @override
+ */
+links.Timeline.ItemDot.prototype.unselect = function () {
+ var dom = this.dom;
+ links.Timeline.removeClassName(dom, 'timeline-event-selected ui-state-active');
+};
+
+/**
+ * Creates the DOM for the item, depending on its type
+ * @return {Element | undefined}
+ * @override
+ */
+links.Timeline.ItemDot.prototype.createDOM = function () {
+ // background box
+ var divBox = document.createElement("DIV");
+ divBox.style.position = "absolute";
+
+ // contents box, right from the dot
+ var divContent = document.createElement("DIV");
+ divContent.className = "timeline-event-content";
+ divBox.appendChild(divContent);
+
+ // dot at start
+ var divDot = document.createElement("DIV");
+ divDot.style.position = "absolute";
+ divDot.style.width = "0px";
+ divDot.style.height = "0px";
+ divBox.appendChild(divDot);
+
+ divBox.content = divContent;
+ divBox.dot = divDot;
+
+ this.dom = divBox;
+ this.updateDOM();
+
+ return divBox;
+};
+
+/**
+ * Append the items DOM to the given HTML container. If items DOM does not yet
+ * exist, it will be created first.
+ * @param {Element} container
+ * @override
+ */
+links.Timeline.ItemDot.prototype.showDOM = function (container) {
+ var dom = this.dom;
+ if (!dom) {
+ dom = this.createDOM();
+ }
+
+ if (dom.parentNode != container) {
+ if (dom.parentNode) {
+ // container changed. remove it from old container first
+ this.hideDOM();
+ }
+
+ // append to container
+ container.appendChild(dom);
+ this.rendered = true;
+ }
+};
+
+/**
+ * Remove the items DOM from the current HTML container
+ * @override
+ */
+links.Timeline.ItemDot.prototype.hideDOM = function () {
+ var dom = this.dom;
+ if (dom) {
+ if (dom.parentNode) {
+ dom.parentNode.removeChild(dom);
+ }
+ this.rendered = false;
+ }
+};
+
+/**
+ * Update the DOM of the item. This will update the content and the classes
+ * of the item
+ * @override
+ */
+links.Timeline.ItemDot.prototype.updateDOM = function () {
+ if (this.dom) {
+ var divBox = this.dom;
+ var divDot = divBox.dot;
+
+ // update contents
+ divBox.firstChild.innerHTML = this.content;
+
+ // update classes
+ divBox.className = "timeline-event-dot-container";
+ divDot.className = "timeline-event timeline-event-dot ui-widget ui-state-default";
+
+ if (this.isCluster) {
+ links.Timeline.addClassName(divBox, 'timeline-event-cluster ui-widget-header');
+ links.Timeline.addClassName(divDot, 'timeline-event-cluster ui-widget-header');
+ }
+
+ // add item specific class name when provided
+ if (this.className) {
+ links.Timeline.addClassName(divBox, this.className);
+ links.Timeline.addClassName(divDot, this.className);
+ }
+
+ // TODO: apply selected className?
+ }
+};
+
+/**
+ * Reposition the item, recalculate its left, top, and width, using the current
+ * range of the timeline and the timeline options. *
+ * @param {links.Timeline} timeline
+ * @override
+ */
+links.Timeline.ItemDot.prototype.updatePosition = function (timeline) {
+ var dom = this.dom;
+ if (dom) {
+ var left = timeline.timeToScreen(this.start);
+
+ dom.style.top = this.top + "px";
+ dom.style.left = (left - this.dotWidth / 2) + "px";
+
+ dom.content.style.marginLeft = (1.5 * this.dotWidth) + "px";
+ //dom.content.style.marginRight = (0.5 * this.dotWidth) + "px"; // TODO
+ dom.dot.style.top = ((this.height - this.dotHeight) / 2) + "px";
+ }
+};
+
+/**
+ * Check if the item is visible in the timeline, and not part of a cluster.
+ * @param {Date} start
+ * @param {Date} end
+ * @return {boolean} visible
+ * @override
+ */
+links.Timeline.ItemDot.prototype.isVisible = function (start, end) {
+ if (this.cluster) {
+ return false;
+ }
+
+ return (this.start > start)
+ && (this.start < end);
+};
+
+/**
+ * Reposition the item
+ * @param {Number} left
+ * @param {Number} right
+ * @override
+ */
+links.Timeline.ItemDot.prototype.setPosition = function (left, right) {
+ var dom = this.dom;
+
+ dom.style.left = (left - this.dotWidth / 2) + "px";
+
+ if (this.group) {
+ this.top = this.group.top;
+ dom.style.top = this.top + 'px';
+ }
+};
+
+/**
+ * Calculate the left position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} left
+ * @override
+ */
+links.Timeline.ItemDot.prototype.getLeft = function (timeline) {
+ return timeline.timeToScreen(this.start);
+};
+
+/**
+ * Calculate the right position of the item
+ * @param {links.Timeline} timeline
+ * @return {Number} right
+ * @override
+ */
+links.Timeline.ItemDot.prototype.getRight = function (timeline) {
+ return timeline.timeToScreen(this.start) + this.width;
+};
+
+/**
+ * Retrieve the properties of an item.
+ * @param {Number} index
+ * @return {Object} itemData Object containing item properties:
+ * {Date} start (required),
+ * {Date} end (optional),
+ * {String} content (required),
+ * {String} group (optional),
+ * {String} className (optional)
+ * {boolean} editable (optional)
+ * {String} type (optional)
+ */
+links.Timeline.prototype.getItem = function (index) {
+ if (index >= this.items.length) {
+ throw "Cannot get item, index out of range";
+ }
+
+ // take the original data as start, includes foreign fields
+ var data = this.data,
+ itemData;
+ if (google && google.visualization &&
+ data instanceof google.visualization.DataTable) {
+ // map the datatable columns
+ var cols = links.Timeline.mapColumnIds(data);
+
+ itemData = {};
+ for (var col in cols) {
+ if (cols.hasOwnProperty(col)) {
+ itemData[col] = this.data.getValue(index, cols[col]);
+ }
+ }
+ } else if (links.Timeline.isArray(this.data)) {
+ // read JSON array
+ itemData = links.Timeline.clone(this.data[index]);
+ } else {
+ throw "Unknown data type. DataTable or Array expected.";
+ }
+
+ // override the data with current settings of the item (should be the same)
+ var item = this.items[index];
+
+ itemData.start = new Date(item.start.valueOf());
+ if (item.end) {
+ itemData.end = new Date(item.end.valueOf());
+ }
+ itemData.content = item.content;
+ if (item.group) {
+ itemData.group = this.getGroupName(item.group);
+ }
+ if (item.className) {
+ itemData.className = item.className;
+ }
+ if (typeof item.editable !== 'undefined') {
+ itemData.editable = item.editable;
+ }
+ if (item.type) {
+ itemData.type = item.type;
+ }
+
+ return itemData;
+};
+
+
+/**
+ * Retrieve the properties of a cluster.
+ * @param {Number} index
+ * @return {Object} clusterdata Object containing cluster properties:
+ * {Date} start (required),
+ * {String} type (optional)
+ * {Array} array with item data as is in getItem()
+ */
+links.Timeline.prototype.getCluster = function (index) {
+ if (index >= this.clusters.length) {
+ throw "Cannot get cluster, index out of range";
+ }
+
+ var clusterData = {},
+ cluster = this.clusters[index],
+ clusterItems = cluster.items;
+
+ clusterData.start = new Date(cluster.start.valueOf());
+ if (cluster.type) {
+ clusterData.type = cluster.type;
+ }
+
+ // push cluster item data
+ clusterData.items = [];
+ for (var i = 0; i < clusterItems.length; i++) {
+ for (var j = 0; j < this.items.length; j++) {
+ // TODO could be nicer to be able to have the item index into the cluster
+ if (this.items[j] == clusterItems[i]) {
+ clusterData.items.push(this.getItem(j));
+ break;
+ }
+ }
+ }
+
+ return clusterData;
+};
+
+/**
+ * Add a new item.
+ * @param {Object} itemData Object containing item properties:
+ * {Date} start (required),
+ * {Date} end (optional),
+ * {String} content (required),
+ * {String} group (optional)
+ * {String} className (optional)
+ * {Boolean} editable (optional)
+ * {String} type (optional)
+ * @param {boolean} [preventRender=false] Do not re-render timeline if true
+ */
+links.Timeline.prototype.addItem = function (itemData, preventRender) {
+ var itemsData = [
+ itemData
+ ];
+
+ this.addItems(itemsData, preventRender);
+};
+
+/**
+ * Add new items.
+ * @param {Array} itemsData An array containing Objects.
+ * The objects must have the following parameters:
+ * {Date} start,
+ * {Date} end,
+ * {String} content with text or HTML code,
+ * {String} group (optional)
+ * {String} className (optional)
+ * {String} editable (optional)
+ * {String} type (optional)
+ * @param {boolean} [preventRender=false] Do not re-render timeline if true
+ */
+links.Timeline.prototype.addItems = function (itemsData, preventRender) {
+ var timeline = this,
+ items = this.items;
+
+ // append the items
+ itemsData.forEach(function (itemData) {
+ var index = items.length;
+ items.push(timeline.createItem(itemData));
+ timeline.updateData(index, itemData);
+
+ // note: there is no need to add the item to the renderQueue, that
+ // will be done when this.render() is executed and all items are
+ // filtered again.
+ });
+
+ // prepare data for clustering, by filtering and sorting by type
+ if (this.options.cluster) {
+ this.clusterGenerator.updateData();
+ }
+
+ if (!preventRender) {
+ this.render({
+ animate: false
+ });
+ }
+};
+
+/**
+ * Create an item object, containing all needed parameters
+ * @param {Object} itemData Object containing parameters start, end
+ * content, group.
+ * @return {Object} item
+ */
+links.Timeline.prototype.createItem = function (itemData) {
+ var type = itemData.type || (itemData.end ? 'range' : this.options.style);
+ var data = links.Timeline.clone(itemData);
+ data.type = type;
+ data.group = this.getGroup(itemData.group);
+ // TODO: optimize this, when creating an item, all data is copied twice...
+
+ // TODO: is initialTop needed?
+ var initialTop,
+ options = this.options;
+ if (options.axisOnTop) {
+ initialTop = this.size.axis.height + options.eventMarginAxis + options.eventMargin / 2;
+ } else {
+ initialTop = this.size.contentHeight - options.eventMarginAxis - options.eventMargin / 2;
+ }
+
+ if (type in this.itemTypes) {
+ return new this.itemTypes[type](data, {'top': initialTop})
+ }
+
+ console.log('ERROR: Unknown event type "' + type + '"');
+ return new links.Timeline.Item(data, {
+ 'top': initialTop
+ });
+};
+
+/**
+ * Edit an item
+ * @param {Number} index
+ * @param {Object} itemData Object containing item properties:
+ * {Date} start (required),
+ * {Date} end (optional),
+ * {String} content (required),
+ * {String} group (optional)
+ * @param {boolean} [preventRender=false] Do not re-render timeline if true
+ */
+links.Timeline.prototype.changeItem = function (index, itemData, preventRender) {
+ var oldItem = this.items[index];
+ if (!oldItem) {
+ throw "Cannot change item, index out of range";
+ }
+
+ // replace item, merge the changes
+ var newItem = this.createItem({
+ 'start': itemData.hasOwnProperty('start') ? itemData.start : oldItem.start,
+ 'end': itemData.hasOwnProperty('end') ? itemData.end : oldItem.end,
+ 'content': itemData.hasOwnProperty('content') ? itemData.content : oldItem.content,
+ 'group': itemData.hasOwnProperty('group') ? itemData.group : this.getGroupName(oldItem.group),
+ 'className': itemData.hasOwnProperty('className') ? itemData.className : oldItem.className,
+ 'editable': itemData.hasOwnProperty('editable') ? itemData.editable : oldItem.editable,
+ 'type': itemData.hasOwnProperty('type') ? itemData.type : oldItem.type
+ });
+ this.items[index] = newItem;
+
+ // append the changes to the render queue
+ this.renderQueue.hide.push(oldItem);
+ this.renderQueue.show.push(newItem);
+
+ // update the original data table
+ this.updateData(index, itemData);
+
+ // prepare data for clustering, by filtering and sorting by type
+ if (this.options.cluster) {
+ this.clusterGenerator.updateData();
+ }
+
+ if (!preventRender) {
+ // redraw timeline
+ this.render({
+ animate: false
+ });
+
+ if (this.selection && this.selection.index == index) {
+ newItem.select();
+ }
+ }
+};
+
+/**
+ * Delete all groups
+ */
+links.Timeline.prototype.deleteGroups = function () {
+ this.groups = [];
+ this.groupIndexes = {};
+};
+
+
+/**
+ * Get a group by the group name. When the group does not exist,
+ * it will be created.
+ * @param {String} groupName the name of the group
+ * @return {Object} groupObject
+ */
+links.Timeline.prototype.getGroup = function (groupName) {
+ var groups = this.groups,
+ groupIndexes = this.groupIndexes,
+ groupObj = undefined;
+
+ var groupIndex = groupIndexes[groupName];
+ if (groupIndex == undefined && groupName != undefined) { // not null or undefined
+ groupObj = {
+ 'content': groupName,
+ 'labelTop': 0,
+ 'lineTop': 0
+ // note: this object will lateron get addition information,
+ // such as height and width of the group
+ };
+ groups.push(groupObj);
+ // sort the groups
+ if (this.options.groupsOrder == true) {
+ groups = groups.sort(function (a, b) {
+ if (a.content > b.content) {
+ return 1;
+ }
+ if (a.content < b.content) {
+ return -1;
+ }
+ return 0;
+ });
+ } else if (typeof(this.options.groupsOrder) == "function") {
+ groups = groups.sort(this.options.groupsOrder)
+ }
+
+ // rebuilt the groupIndexes
+ for (var i = 0, iMax = groups.length; i < iMax; i++) {
+ groupIndexes[groups[i].content] = i;
+ }
+ } else {
+ groupObj = groups[groupIndex];
+ }
+
+ return groupObj;
+};
+
+/**
+ * Get the group name from a group object.
+ * @param {Object} groupObj
+ * @return {String} groupName the name of the group, or undefined when group
+ * was not provided
+ */
+links.Timeline.prototype.getGroupName = function (groupObj) {
+ return groupObj ? groupObj.content : undefined;
+};
+
+/**
+ * Cancel a change item
+ * This method can be called insed an event listener which catches the "change"
+ * event. The changed event position will be undone.
+ */
+links.Timeline.prototype.cancelChange = function () {
+ this.applyChange = false;
+};
+
+/**
+ * Cancel deletion of an item
+ * This method can be called insed an event listener which catches the "delete"
+ * event. Deletion of the event will be undone.
+ */
+links.Timeline.prototype.cancelDelete = function () {
+ this.applyDelete = false;
+};
+
+
+/**
+ * Cancel creation of a new item
+ * This method can be called insed an event listener which catches the "new"
+ * event. Creation of the new the event will be undone.
+ */
+links.Timeline.prototype.cancelAdd = function () {
+ this.applyAdd = false;
+};
+
+
+/**
+ * Select an event. The visible chart range will be moved such that the selected
+ * event is placed in the middle.
+ * For example selection = [{row: 5}];
+ * @param {Array} selection An array with a column row, containing the row
+ * number (the id) of the event to be selected.
+ * @return {boolean} true if selection is succesfully set, else false.
+ */
+links.Timeline.prototype.setSelection = function (selection) {
+ if (selection != undefined && selection.length > 0) {
+ if (selection[0].row != undefined) {
+ var index = selection[0].row;
+ if (this.items[index]) {
+ var item = this.items[index];
+ this.selectItem(index);
+
+ // move the visible chart range to the selected event.
+ var start = item.start;
+ var end = item.end;
+ var middle; // number
+ if (end != undefined) {
+ middle = (end.valueOf() + start.valueOf()) / 2;
+ } else {
+ middle = start.valueOf();
+ }
+ var diff = (this.end.valueOf() - this.start.valueOf()),
+ newStart = new Date(middle - diff/2),
+ newEnd = new Date(middle + diff/2);
+
+ this.setVisibleChartRange(newStart, newEnd);
+
+ return true;
+ }
+ }
+ } else {
+ // unselect current selection
+ this.unselectItem();
+ }
+ return false;
+};
+
+/**
+ * Retrieve the currently selected event
+ * @return {Array} sel An array with a column row, containing the row number
+ * of the selected event. If there is no selection, an
+ * empty array is returned.
+ */
+links.Timeline.prototype.getSelection = function () {
+ var sel = [];
+ if (this.selection) {
+ if (this.selection.index !== undefined) {
+ sel.push({"row": this.selection.index});
+ } else {
+ sel.push({"cluster": this.selection.cluster});
+ }
+ }
+ return sel;
+};
+
+
+/**
+ * Select an item by its index
+ * @param {Number} index
+ */
+links.Timeline.prototype.selectItem = function (index) {
+ this.unselectItem();
+
+ this.selection = undefined;
+
+ if (this.items[index] != undefined) {
+ var item = this.items[index],
+ domItem = item.dom;
+
+ this.selection = {
+ 'index': index
+ };
+
+ if (item && item.dom) {
+ // TODO: move adjusting the domItem to the item itself
+ if (this.isEditable(item)) {
+ item.dom.style.cursor = 'move';
+ }
+ item.select();
+ }
+ this.repaintDeleteButton();
+ this.repaintDragAreas();
+ }
+};
+
+/**
+ * Select an cluster by its index
+ * @param {Number} index
+ */
+links.Timeline.prototype.selectCluster = function (index) {
+ this.unselectItem();
+
+ this.selection = undefined;
+
+ if (this.clusters[index] != undefined) {
+ this.selection = {
+ 'cluster': index
+ };
+ this.repaintDeleteButton();
+ this.repaintDragAreas();
+ }
+};
+
+/**
+ * Check if an item is currently selected
+ * @param {Number} index
+ * @return {boolean} true if row is selected, else false
+ */
+links.Timeline.prototype.isSelected = function (index) {
+ return (this.selection && this.selection.index == index);
+};
+
+/**
+ * Unselect the currently selected event (if any)
+ */
+links.Timeline.prototype.unselectItem = function () {
+ if (this.selection && this.selection.index !== undefined) {
+ var item = this.items[this.selection.index];
+
+ if (item && item.dom) {
+ var domItem = item.dom;
+ domItem.style.cursor = '';
+ item.unselect();
+ }
+
+ this.selection = undefined;
+ this.repaintDeleteButton();
+ this.repaintDragAreas();
+ }
+};
+
+
+/**
+ * Stack the items such that they don't overlap. The items will have a minimal
+ * distance equal to options.eventMargin.
+ * @param {boolean | undefined} animate if animate is true, the items are
+ * moved to their new position animated
+ * defaults to false.
+ */
+links.Timeline.prototype.stackItems = function (animate) {
+ if (animate == undefined) {
+ animate = false;
+ }
+
+ // calculate the order and final stack position of the items
+ var stack = this.stack;
+ if (!stack) {
+ stack = {};
+ this.stack = stack;
+ }
+ stack.sortedItems = this.stackOrder(this.renderedItems);
+ stack.finalItems = this.stackCalculateFinal(stack.sortedItems);
+
+ if (animate || stack.timer) {
+ // move animated to the final positions
+ var timeline = this;
+ var step = function () {
+ var arrived = timeline.stackMoveOneStep(
+ stack.sortedItems,
+ stack.finalItems
+ );
+
+ timeline.repaint();
+
+ if (!arrived) {
+ stack.timer = setTimeout(step, 30);
+ } else {
+ delete stack.timer;
+ }
+ };
+
+ if (!stack.timer) {
+ stack.timer = setTimeout(step, 30);
+ }
+ } else {
+ // move immediately to the final positions
+ this.stackMoveToFinal(stack.sortedItems, stack.finalItems);
+ }
+};
+
+/**
+ * Cancel any running animation
+ */
+links.Timeline.prototype.stackCancelAnimation = function () {
+ if (this.stack && this.stack.timer) {
+ clearTimeout(this.stack.timer);
+ delete this.stack.timer;
+ }
+};
+
+links.Timeline.prototype.getItemsByGroup = function (items) {
+ var itemsByGroup = {};
+ for (var i = 0; i < items.length; ++i) {
+ var item = items[i];
+ var group = "undefined";
+
+ if (item.group) {
+ if (item.group.content) {
+ group = item.group.content;
+ } else {
+ group = item.group;
+ }
+ }
+
+ if (!itemsByGroup[group]) {
+ itemsByGroup[group] = [];
+ }
+
+ itemsByGroup[group].push(item);
+ }
+
+ return itemsByGroup;
+};
+
+/**
+ * Order the items in the array this.items. The default order is determined via:
+ * - Ranges go before boxes and dots.
+ * - The item with the oldest start time goes first
+ * If a custom function has been provided via the stackorder option, then this will be used.
+ * @param {Array} items Array with items
+ * @return {Array} sortedItems Array with sorted items
+ */
+links.Timeline.prototype.stackOrder = function (items) {
+ // TODO: store the sorted items, to have less work later on
+ var sortedItems = items.concat([]);
+
+ //if a customer stack order function exists, use it.
+ var f = this.options.customStackOrder && (typeof this.options.customStackOrder === 'function') ? this.options.customStackOrder : function (a, b) {
+
+ if ((a instanceof links.Timeline.ItemRange || a instanceof links.Timeline.ItemFloatingRange) &&
+ !(b instanceof links.Timeline.ItemRange || b instanceof links.Timeline.ItemFloatingRange)) {
+ return -1;
+ }
+
+ if (!(a instanceof links.Timeline.ItemRange || a instanceof links.Timeline.ItemFloatingRange) &&
+ (b instanceof links.Timeline.ItemRange || b instanceof links.Timeline.ItemFloatingRange)) {
+ return 1;
+ }
+
+ return (a.left - b.left);
+ };
+
+ sortedItems.sort(f);
+
+ return sortedItems;
+};
+
+/**
+ * Adjust vertical positions of the events such that they don't overlap each
+ * other.
+ * @param {timeline.Item[]} items
+ * @return {Object[]} finalItems
+ */
+links.Timeline.prototype.stackCalculateFinal = function (items) {
+ var size = this.size,
+ options = this.options,
+ axisOnTop = options.axisOnTop,
+ eventMargin = options.eventMargin,
+ eventMarginAxis = options.eventMarginAxis,
+ groupBase = (axisOnTop)
+ ? size.axis.height + eventMarginAxis + eventMargin/2
+ : size.contentHeight - eventMarginAxis - eventMargin/2,
+ groupedItems, groupFinalItems, finalItems = [];
+
+ groupedItems = this.getItemsByGroup(items);
+
+ //
+ // groupedItems contains all items by group, plus it may contain an
+ // additional "undefined" group which contains all items with no group. We
+ // first process the grouped items, and then the ungrouped
+ //
+ for (j = 0; j topNow) ? 1 : -1);
+ if (Math.abs(diff) > 4) {
+step = diff / 4; }
+ var topNew = parseInt(topNow + step);
+
+ if (topNew != topFinal) {
+ arrived = false;
+ }
+
+ item.top = topNew;
+ item.bottom = item.top + item.height;
+ } else {
+ item.top = finalItem.top;
+ item.bottom = finalItem.bottom;
+ }
+
+ item.left = finalItem.left;
+ item.right = finalItem.right;
+ }
+
+ return arrived;
+};
+
+
+
+/**
+ * Move the events from their current position to the final position
+ * @param {Array} currentItems Array with the real items and their current
+ * positions
+ * @param {Array} finalItems Array with objects containing the final
+ * positions of the items
+ */
+links.Timeline.prototype.stackMoveToFinal = function (currentItems, finalItems) {
+ // Put the events directly at there final position
+ for (var i = 0, iMax = finalItems.length; i < iMax; i++) {
+ var finalItem = finalItems[i],
+ current = finalItem.item;
+
+ current.left = finalItem.left;
+ current.top = finalItem.top;
+ current.right = finalItem.right;
+ current.bottom = finalItem.bottom;
+ }
+};
+
+
+
+/**
+ * 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} colliding item, or undefined when no collisions
+ */
+links.Timeline.prototype.stackItemsCheckOverlap = function (
+ items,
+ itemIndex,
+ itemStart,
+ itemEnd
+) {
+ var eventMargin = this.options.eventMargin,
+ 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 item1 = items[itemIndex];
+ for (var i = itemEnd; i >= itemStart; i--) {
+ var item2 = items[i];
+ if (collision(item1, item2, eventMargin)) {
+ if (i != itemIndex) {
+ return item2;
+ }
+ }
+ }
+
+ return undefined;
+};
+
+/**
+ * Test if the two provided items collide
+ * The items must have parameters left, right, top, and bottom.
+ * @param {Element} item1 The first item
+ * @param {Element} item2 The second item
+ * @param {Number} margin A minimum required margin. Optional.
+ * 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 item1 and item2 collide, else false
+ */
+links.Timeline.prototype.collision = function (item1, item2, margin) {
+ // set margin if not specified
+ if (margin == undefined) {
+ margin = 0;
+ }
+
+ // calculate if there is overlap (collision)
+ return (item1.left - margin < item2.right &&
+ item1.right + margin > item2.left &&
+ item1.top - margin < item2.bottom &&
+ item1.bottom + margin > item2.top);
+};
+
+
+/**
+ * fire an event
+ * @param {String} event The name of an event, for example "rangechange" or "edit"
+ */
+links.Timeline.prototype.trigger = function (event) {
+ // built up properties
+ var properties = null;
+ switch (event) {
+ case 'rangechange':
+ case 'rangechanged':
+ properties = {
+ 'start': new Date(this.start.valueOf()),
+ 'end': new Date(this.end.valueOf())
+ };
+ break;
+
+ case 'timechange':
+ case 'timechanged':
+ properties = {
+ 'time': new Date(this.customTime.valueOf())
+ };
+ break;
+ }
+
+ // trigger the links event bus
+ links.events.trigger(this, event, properties);
+
+ // trigger the google event bus
+ if (google && google.visualization) {
+ google.visualization.events.trigger(this, event, properties);
+ }
+};
+
+
+/**
+ * Cluster the events
+ */
+links.Timeline.prototype.clusterItems = function () {
+ if (!this.options.cluster) {
+ return;
+ }
+
+ var clusters = this.clusterGenerator.getClusters(this.conversion.factor, this.options.clusterMaxItems);
+ if (this.clusters != clusters) {
+ // cluster level changed
+ var queue = this.renderQueue;
+
+ // remove the old clusters from the scene
+ if (this.clusters) {
+ this.clusters.forEach(function (cluster) {
+ queue.hide.push(cluster);
+
+ // unlink the items
+ cluster.items.forEach(function (item) {
+ item.cluster = undefined;
+ });
+ });
+ }
+
+ // append the new clusters
+ clusters.forEach(function (cluster) {
+ // don't add to the queue.show here, will be done in .filterItems()
+
+ // link all items to the cluster
+ cluster.items.forEach(function (item) {
+ item.cluster = cluster;
+ });
+ });
+
+ this.clusters = clusters;
+ }
+};
+
+/**
+ * Filter the visible events
+ */
+links.Timeline.prototype.filterItems = function () {
+ var queue = this.renderQueue,
+ window = (this.end - this.start),
+ start = new Date(this.start.valueOf() - window),
+ end = new Date(this.end.valueOf() + window);
+
+ function filter(arr)
+ {
+ arr.forEach(function (item) {
+ var rendered = item.rendered;
+ var visible = item.isVisible(start, end);
+ if (rendered != visible) {
+ if (rendered) {
+ queue.hide.push(item); // item is rendered but no longer visible
+ }
+ if (visible && (queue.show.indexOf(item) == -1)) {
+ queue.show.push(item); // item is visible but neither rendered nor queued up to be rendered
+ }
+ }
+ });
+ }
+
+ // filter all items and all clusters
+ filter(this.items);
+ if (this.clusters) {
+ filter(this.clusters);
+ }
+};
+
+/** ------------------------------------------------------------------------ **/
+
+/**
+ * @constructor links.Timeline.ClusterGenerator
+ * Generator which creates clusters of items, based on the visible range in
+ * the Timeline. There is a set of cluster levels which is cached.
+ * @param {links.Timeline} timeline
+ */
+links.Timeline.ClusterGenerator = function (timeline) {
+ this.timeline = timeline;
+ this.clear();
+};
+
+/**
+ * Clear all cached clusters and data, and initialize all variables
+ */
+links.Timeline.ClusterGenerator.prototype.clear = function () {
+ // cache containing created clusters for each cluster level
+ this.items = [];
+ this.groups = {};
+ this.clearCache();
+};
+
+/**
+ * Clear the cached clusters
+ */
+links.Timeline.ClusterGenerator.prototype.clearCache = function () {
+ // cache containing created clusters for each cluster level
+ this.cache = {};
+ this.cacheLevel = -1;
+ this.cache[this.cacheLevel] = [];
+};
+
+/**
+ * Set the items to be clustered.
+ * This will clear cached clusters.
+ * @param {Item[]} items
+ * @param {Object} [options] Available options:
+ * {boolean} applyOnChangedLevel
+ * If true (default), the changed data is applied
+ * as soon the cluster level changes. If false,
+ * The changed data is applied immediately
+ */
+links.Timeline.ClusterGenerator.prototype.setData = function (items, options) {
+ this.items = items || [];
+ this.dataChanged = true;
+ this.applyOnChangedLevel = true;
+ if (options && options.applyOnChangedLevel) {
+ this.applyOnChangedLevel = options.applyOnChangedLevel;
+ }
+ // console.log('clustergenerator setData applyOnChangedLevel=' + this.applyOnChangedLevel); // TODO: cleanup
+};
+
+/**
+ * Update the current data set: clear cache, and recalculate the clustering for
+ * the current level
+ */
+links.Timeline.ClusterGenerator.prototype.updateData = function () {
+ this.dataChanged = true;
+ this.applyOnChangedLevel = false;
+};
+
+/**
+ * Filter the items per group.
+ * @private
+ */
+links.Timeline.ClusterGenerator.prototype.filterData = function () {
+ // filter per group
+ var items = this.items || [];
+ var groups = {};
+ this.groups = groups;
+
+ // split the items per group
+ items.forEach(function (item) {
+ // put the item in the correct group
+ var groupName = item.group ? item.group.content : '';
+ var group = groups[groupName];
+ if (!group) {
+ group = [];
+ groups[groupName] = group;
+ }
+ group.push(item);
+
+ // calculate the center of the item
+ if (item.start) {
+ if (item.end) {
+ // range
+ item.center = (item.start.valueOf() + item.end.valueOf()) / 2;
+ } else {
+ // box, dot
+ item.center = item.start.valueOf();
+ }
+ }
+ });
+
+ // sort the items per group
+ for (var groupName in groups) {
+ if (groups.hasOwnProperty(groupName)) {
+ groups[groupName].sort(function (a, b) {
+ return (a.center - b.center);
+ });
+ }
+ }
+
+ this.dataChanged = false;
+};
+
+/**
+ * Cluster the events which are too close together
+ * @param {Number} scale The scale of the current window,
+ * defined as (windowWidth / (endDate - startDate))
+ * @return {Item[]} clusters
+ */
+links.Timeline.ClusterGenerator.prototype.getClusters = function (scale, maxItems) {
+ var level = -1,
+ granularity = 2, // TODO: what granularity is needed for the cluster levels?
+ timeWindow = 0; // milliseconds
+
+ if (scale > 0) {
+ level = Math.round(Math.log(100 / scale) / Math.log(granularity));
+ timeWindow = Math.pow(granularity, level);
+ }
+
+ // clear the cache when and re-filter the data when needed.
+ if (this.dataChanged) {
+ var levelChanged = (level != this.cacheLevel);
+ var applyDataNow = this.applyOnChangedLevel ? levelChanged : true;
+ if (applyDataNow) {
+ // TODO: currently drawn clusters should be removed! mark them as invisible?
+ this.clearCache();
+ this.filterData();
+ // console.log('clustergenerator: cache cleared...'); // TODO: cleanup
+ }
+ }
+
+ this.cacheLevel = level;
+ var clusters = this.cache[level];
+ if (!clusters) {
+ // console.log('clustergenerator: create cluster level ' + level); // TODO: cleanup
+ clusters = [];
+
+ // TODO: spit this method, it is too large
+ for (var groupName in this.groups) {
+ if (this.groups.hasOwnProperty(groupName)) {
+ var items = this.groups[groupName];
+ var iMax = items.length;
+ var i = 0;
+ while (i < iMax) {
+ // find all items around current item, within the timeWindow
+ var item = items[i];
+ var neighbors = 1; // start at 1, to include itself)
+
+ // loop through items left from the current item
+ var j = i - 1;
+ while (j >= 0 && (item.center - items[j].center) < timeWindow / 2) {
+ if (!items[j].cluster) {
+ neighbors++;
+ }
+ j--;
+ }
+
+ // loop through items right from the current item
+ var k = i + 1;
+ while (k < items.length && (items[k].center - item.center) < timeWindow / 2) {
+ neighbors++;
+ k++;
+ }
+
+ // loop through the created clusters
+ var l = clusters.length - 1;
+ while (l >= 0 && (item.center - clusters[l].center) < timeWindow / 2) {
+ if (item.group == clusters[l].group) {
+ neighbors++;
+ }
+ l--;
+ }
+
+ // aggregate until the number of items is within maxItems
+ if (neighbors > maxItems) {
+ // too busy in this window.
+ var num = neighbors - maxItems + 1;
+ var clusterItems = [];
+
+ // append the items to the cluster,
+ // and calculate the average start for the cluster
+ var avg = undefined; // number. average of all start dates
+ var min = undefined; // number. minimum of all start dates
+ var max = undefined; // number. maximum of all start and end dates
+ var containsRanges = false;
+ var count = 0;
+ var m = i;
+ while (clusterItems.length < num && m < items.length) {
+ var p = items[m];
+ var start = p.start.valueOf();
+ var end = p.end ? p.end.valueOf() : p.start.valueOf();
+ clusterItems.push(p);
+ if (count) {
+ // calculate new average (use fractions to prevent overflow)
+ avg = (count / (count + 1)) * avg + (1 / (count + 1)) * p.center;
+ } else {
+ avg = p.center;
+ }
+ min = (min != undefined) ? Math.min(min, start) : start;
+ max = (max != undefined) ? Math.max(max, end) : end;
+ containsRanges = containsRanges || (p instanceof links.Timeline.ItemRange || p instanceof links.Timeline.ItemFloatingRange);
+ count++;
+ m++;
+ }
+
+ var cluster;
+ var title = 'Cluster containing ' + count +
+ ' events. Zoom in to see the individual events.';
+ var content = '
' + count + ' events
';
+ var group = item.group ? item.group.content : undefined;
+ if (containsRanges) {
+ // boxes and/or ranges
+ cluster = this.timeline.createItem({
+ 'start': new Date(min),
+ 'end': new Date(max),
+ 'content': content,
+ 'group': group
+ });
+ } else {
+ // boxes only
+ cluster = this.timeline.createItem({
+ 'start': new Date(avg),
+ 'content': content,
+ 'group': group
+ });
+ }
+ cluster.isCluster = true;
+ cluster.items = clusterItems;
+ cluster.items.forEach(function (item) {
+ item.cluster = cluster;
+ });
+
+ clusters.push(cluster);
+ i += num;
+ } else {
+ delete item.cluster;
+ i += 1;
+ }
+ }
+ }
+ }
+
+ this.cache[level] = clusters;
+ }
+
+ return clusters;
+};
+
+
+/** ------------------------------------------------------------------------ **/
+
+
+/**
+ * Event listener (singleton)
+ */
+links.events = links.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) {
+ var 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);
+ }
+ }
+ }
+ }
+};
+
+
+/** ------------------------------------------------------------------------ **/
+
+/**
+ * @constructor links.Timeline.StepDate
+ * The class StepDate 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 start(). 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 end(). After each step, you can
+ * retrieve the current date via get().
+ * The class step 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
+ */
+links.Timeline.StepDate = function (start, end, minimumStep) {
+
+ // variables
+ this.current = new Date();
+ this._start = new Date();
+ this._end = new Date();
+
+ this.autoScale = true;
+ this.scale = links.Timeline.StepDate.SCALE.DAY;
+ this.step = 1;
+
+ // initialize the range
+ this.setRange(start, end, minimumStep);
+};
+
+/// enum scale
+links.Timeline.StepDate.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
+ */
+links.Timeline.StepDate.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 step iterator to the start date.
+ */
+links.Timeline.StepDate.prototype.start = 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
+ */
+links.Timeline.StepDate.prototype.roundToMinor = function () {
+ // round to floor
+ // IMPORTANT: we have no breaks in this switch! (this is no bug)
+ //noinspection FallthroughInSwitchStatementJS
+ switch (this.scale) {
+ case links.Timeline.StepDate.SCALE.YEAR:
+ this.current.setFullYear(this.step * Math.floor(this.current.getFullYear() / this.step));
+ this.current.setMonth(0);
+ case links.Timeline.StepDate.SCALE.MONTH: this.current.setDate(1);
+ case links.Timeline.StepDate.SCALE.DAY: // intentional fall through
+ case links.Timeline.StepDate.SCALE.WEEKDAY: this.current.setHours(0);
+ case links.Timeline.StepDate.SCALE.HOUR: this.current.setMinutes(0);
+ case links.Timeline.StepDate.SCALE.MINUTE: this.current.setSeconds(0);
+ case links.Timeline.StepDate.SCALE.SECOND: this.current.setMilliseconds(0);
+ //case links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.MILLISECOND: this.current.setMilliseconds(this.current.getMilliseconds() - this.current.getMilliseconds() % this.step); break;
+ case links.Timeline.StepDate.SCALE.SECOND: this.current.setSeconds(this.current.getSeconds() - this.current.getSeconds() % this.step); break;
+ case links.Timeline.StepDate.SCALE.MINUTE: this.current.setMinutes(this.current.getMinutes() - this.current.getMinutes() % this.step); break;
+ case links.Timeline.StepDate.SCALE.HOUR: this.current.setHours(this.current.getHours() - this.current.getHours() % this.step); break;
+ case links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through
+ case links.Timeline.StepDate.SCALE.DAY: this.current.setDate((this.current.getDate()-1) - (this.current.getDate()-1) % this.step + 1); break;
+ case links.Timeline.StepDate.SCALE.MONTH: this.current.setMonth(this.current.getMonth() - this.current.getMonth() % this.step); break;
+ case links.Timeline.StepDate.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() - this.current.getFullYear() % this.step); break;
+ default: break;
+ }
+ }
+};
+
+/**
+ * Check if the end date is reached
+ * @return {boolean} true if the current date has passed the end date
+ */
+links.Timeline.StepDate.prototype.end = function () {
+ return (this.current.valueOf() > this._end.valueOf());
+};
+
+/**
+ * Do the next step
+ */
+links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.MILLISECOND:
+
+ this.current = new Date(this.current.valueOf() + this.step); break;
+ case links.Timeline.StepDate.SCALE.SECOND: this.current = new Date(this.current.valueOf() + this.step * 1000); break;
+ case links.Timeline.StepDate.SCALE.MINUTE: this.current = new Date(this.current.valueOf() + this.step * 1000 * 60); break;
+ case links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through
+ case links.Timeline.StepDate.SCALE.DAY: this.current.setDate(this.current.getDate() + this.step); break;
+ case links.Timeline.StepDate.SCALE.MONTH: this.current.setMonth(this.current.getMonth() + this.step); break;
+ case links.Timeline.StepDate.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() + this.step); break;
+ default: break;
+ }
+ } else {
+ switch (this.scale) {
+ case links.Timeline.StepDate.SCALE.MILLISECOND: this.current = new Date(this.current.valueOf() + this.step); break;
+ case links.Timeline.StepDate.SCALE.SECOND: this.current.setSeconds(this.current.getSeconds() + this.step); break;
+ case links.Timeline.StepDate.SCALE.MINUTE: this.current.setMinutes(this.current.getMinutes() + this.step); break;
+ case links.Timeline.StepDate.SCALE.HOUR: this.current.setHours(this.current.getHours() + this.step); break;
+ case links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through
+ case links.Timeline.StepDate.SCALE.DAY: this.current.setDate(this.current.getDate() + this.step); break;
+ case links.Timeline.StepDate.SCALE.MONTH: this.current.setMonth(this.current.getMonth() + this.step); break;
+ case links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.MILLISECOND: if (this.current.getMilliseconds() < this.step) {
+this.current.setMilliseconds(0); } break;
+ case links.Timeline.StepDate.SCALE.SECOND: if (this.current.getSeconds() < this.step) {
+this.current.setSeconds(0); } break;
+ case links.Timeline.StepDate.SCALE.MINUTE: if (this.current.getMinutes() < this.step) {
+this.current.setMinutes(0); } break;
+ case links.Timeline.StepDate.SCALE.HOUR: if (this.current.getHours() < this.step) {
+this.current.setHours(0); } break;
+ case links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through
+ case links.Timeline.StepDate.SCALE.DAY: if (this.current.getDate() < this.step+1) {
+this.current.setDate(1); } break;
+ case links.Timeline.StepDate.SCALE.MONTH: if (this.current.getMonth() < this.step) {
+this.current.setMonth(0); } break;
+ case links.Timeline.StepDate.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
+ */
+links.Timeline.StepDate.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 {links.Timeline.StepDate.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.
+ */
+links.Timeline.StepDate.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
+ */
+links.Timeline.StepDate.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
+ */
+links.Timeline.StepDate.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 = links.Timeline.StepDate.SCALE.YEAR; this.step = 1000;}
+ if (stepYear*500 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 500;}
+ if (stepYear*100 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 100;}
+ if (stepYear*50 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 50;}
+ if (stepYear*10 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 10;}
+ if (stepYear*5 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 5;}
+ if (stepYear > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 1;}
+ if (stepMonth*3 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MONTH; this.step = 3;}
+ if (stepMonth > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MONTH; this.step = 1;}
+ if (stepDay*5 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.DAY; this.step = 5;}
+ if (stepDay*2 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.DAY; this.step = 2;}
+ if (stepDay > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.DAY; this.step = 1;}
+ if (stepDay/2 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.WEEKDAY; this.step = 1;}
+ if (stepHour*4 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.HOUR; this.step = 4;}
+ if (stepHour > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.HOUR; this.step = 1;}
+ if (stepMinute*15 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MINUTE; this.step = 15;}
+ if (stepMinute*10 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MINUTE; this.step = 10;}
+ if (stepMinute*5 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MINUTE; this.step = 5;}
+ if (stepMinute > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MINUTE; this.step = 1;}
+ if (stepSecond*15 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.SECOND; this.step = 15;}
+ if (stepSecond*10 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.SECOND; this.step = 10;}
+ if (stepSecond*5 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.SECOND; this.step = 5;}
+ if (stepSecond > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.SECOND; this.step = 1;}
+ if (stepMillisecond*200 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 200;}
+ if (stepMillisecond*100 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 100;}
+ if (stepMillisecond*50 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 50;}
+ if (stepMillisecond*10 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 10;}
+ if (stepMillisecond*5 > minimumStep) {
+this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 5;}
+ if (stepMillisecond > minimumStep) {
+this.scale = links.Timeline.StepDate.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
+ */
+links.Timeline.StepDate.prototype.snap = function (date) {
+ if (this.scale == links.Timeline.StepDate.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 == links.Timeline.StepDate.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 == links.Timeline.StepDate.SCALE.DAY ||
+ this.scale == links.Timeline.StepDate.SCALE.WEEKDAY) {
+ 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 == links.Timeline.StepDate.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 == links.Timeline.StepDate.SCALE.MINUTE) {
+ 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 == links.Timeline.StepDate.SCALE.SECOND) {
+ 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 == links.Timeline.StepDate.SCALE.MILLISECOND) {
+ var step = this.step > 5 ? this.step / 2 : 1;
+ date.setMilliseconds(Math.round(date.getMilliseconds() / step) * step);
+ }
+};
+
+/**
+ * Check if the current step is a major step (for example when the step
+ * is DAY, a major step is each first day of the MONTH)
+ * @return {boolean} true if current date is major, else false.
+ */
+links.Timeline.StepDate.prototype.isMajor = function () {
+ switch (this.scale) {
+ case links.Timeline.StepDate.SCALE.MILLISECOND:
+ return (this.current.getMilliseconds() == 0);
+ case links.Timeline.StepDate.SCALE.SECOND:
+ return (this.current.getSeconds() == 0);
+ case links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.HOUR:
+ return (this.current.getHours() == 0);
+ case links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through
+ case links.Timeline.StepDate.SCALE.DAY:
+ return (this.current.getDate() == 1);
+ case links.Timeline.StepDate.SCALE.MONTH:
+ return (this.current.getMonth() == 0);
+ case links.Timeline.StepDate.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 {Object} options
+ * @param {Date} [date] custom date. if not provided, current date is taken
+ */
+links.Timeline.StepDate.prototype.getLabelMinor = function (options, date) {
+ if (date == undefined) {
+ date = this.current;
+ }
+
+ switch (this.scale) {
+ case links.Timeline.StepDate.SCALE.MILLISECOND: return String(date.getMilliseconds());
+ case links.Timeline.StepDate.SCALE.SECOND: return String(date.getSeconds());
+ case links.Timeline.StepDate.SCALE.MINUTE:
+ return this.addZeros(date.getHours(), 2) + ":" + this.addZeros(date.getMinutes(), 2);
+ case links.Timeline.StepDate.SCALE.HOUR:
+ return this.addZeros(date.getHours(), 2) + ":" + this.addZeros(date.getMinutes(), 2);
+ case links.Timeline.StepDate.SCALE.WEEKDAY: return options.DAYS_SHORT[date.getDay()] + ' ' + date.getDate();
+ case links.Timeline.StepDate.SCALE.DAY: return String(date.getDate());
+ case links.Timeline.StepDate.SCALE.MONTH: return options.MONTHS_SHORT[date.getMonth()]; // month is zero based
+ case links.Timeline.StepDate.SCALE.YEAR: return String(date.getFullYear());
+ 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 {Object} options
+ * @param {Date} [date] custom date. if not provided, current date is taken
+ */
+links.Timeline.StepDate.prototype.getLabelMajor = function (options, date) {
+ if (date == undefined) {
+ date = this.current;
+ }
+
+ switch (this.scale) {
+ case links.Timeline.StepDate.SCALE.MILLISECOND:
+ return this.addZeros(date.getHours(), 2) + ":" +
+ this.addZeros(date.getMinutes(), 2) + ":" +
+ this.addZeros(date.getSeconds(), 2);
+ case links.Timeline.StepDate.SCALE.SECOND:
+ return date.getDate() + " " +
+ options.MONTHS[date.getMonth()] + " " +
+ this.addZeros(date.getHours(), 2) + ":" +
+ this.addZeros(date.getMinutes(), 2);
+ case links.Timeline.StepDate.SCALE.MINUTE:
+ return options.DAYS[date.getDay()] + " " +
+ date.getDate() + " " +
+ options.MONTHS[date.getMonth()] + " " +
+ date.getFullYear();
+ case links.Timeline.StepDate.SCALE.HOUR:
+ return options.DAYS[date.getDay()] + " " +
+ date.getDate() + " " +
+ options.MONTHS[date.getMonth()] + " " +
+ date.getFullYear();
+ case links.Timeline.StepDate.SCALE.WEEKDAY:
+ case links.Timeline.StepDate.SCALE.DAY:
+ return options.MONTHS[date.getMonth()] + " " +
+ date.getFullYear();
+ case links.Timeline.StepDate.SCALE.MONTH:
+ return String(date.getFullYear());
+ default:
+ return "";
+ }
+};
+
+/**
+ * Add leading zeros to the given value to match the desired length.
+ * For example addZeros(123, 5) returns "00123"
+ * @param {int} value A value
+ * @param {int} len Desired final length
+ * @return {string} value with leading zeros
+ */
+links.Timeline.StepDate.prototype.addZeros = function (value, len) {
+ var str = "" + value;
+ while (str.length < len) {
+ str = "0" + str;
+ }
+ return str;
+};
+
+
+
+/** ------------------------------------------------------------------------ **/
+
+/**
+ * Image Loader service.
+ * can be used to get a callback when a certain image is loaded
+ *
+ */
+links.imageloader = (function () {
+ var urls = {}; // the loaded urls
+ var callbacks = {}; // the urls currently being loaded. Each key contains
+ // an array with callbacks
+
+ /**
+ * Check if an image url is loaded
+ * @param {String} url
+ * @return {boolean} loaded True when loaded, false when not loaded
+ * or when being loaded
+ */
+ function isLoaded(url)
+ {
+ if (urls[url] == true) {
+ return true;
+ }
+
+ var image = new Image();
+ image.src = url;
+ if (image.complete) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if an image url is being loaded
+ * @param {String} url
+ * @return {boolean} loading True when being loaded, false when not loading
+ * or when already loaded
+ */
+ function isLoading(url)
+ {
+ return (callbacks[url] != undefined);
+ }
+
+ /**
+ * Load given image url
+ * @param {String} url
+ * @param {function} callback
+ * @param {boolean} sendCallbackWhenAlreadyLoaded optional
+ */
+ function load(url, callback, sendCallbackWhenAlreadyLoaded)
+ {
+ if (sendCallbackWhenAlreadyLoaded == undefined) {
+ sendCallbackWhenAlreadyLoaded = true;
+ }
+
+ if (isLoaded(url)) {
+ if (sendCallbackWhenAlreadyLoaded) {
+ callback(url);
+ }
+ return;
+ }
+
+ if (isLoading(url) && !sendCallbackWhenAlreadyLoaded) {
+ return;
+ }
+
+ var c = callbacks[url];
+ if (!c) {
+ var image = new Image();
+ image.src = url;
+
+ c = [];
+ callbacks[url] = c;
+
+ image.onload = function (event) {
+ urls[url] = true;
+ delete callbacks[url];
+
+ for (var i = 0; i < c.length; i++) {
+ c[i](url);
+ }
+ }
+ }
+
+ if (c.indexOf(callback) == -1) {
+ c.push(callback);
+ }
+ }
+
+ /**
+ * Load a set of images, and send a callback as soon as all images are
+ * loaded
+ * @param {String[]} urls
+ * @param {function } callback
+ * @param {boolean} sendCallbackWhenAlreadyLoaded
+ */
+ function loadAll(urls, callback, sendCallbackWhenAlreadyLoaded)
+ {
+ // list all urls which are not yet loaded
+ var urlsLeft = [];
+ urls.forEach(function (url) {
+ if (!isLoaded(url)) {
+ urlsLeft.push(url);
+ }
+ });
+
+ if (urlsLeft.length) {
+ // there are unloaded images
+ var countLeft = urlsLeft.length;
+ urlsLeft.forEach(function (url) {
+ load(url, function () {
+ countLeft--;
+ if (countLeft == 0) {
+ // done!
+ callback();
+ }
+ }, sendCallbackWhenAlreadyLoaded);
+ });
+ } else {
+ // we are already done!
+ if (sendCallbackWhenAlreadyLoaded) {
+ callback();
+ }
+ }
+ }
+
+ /**
+ * Recursively retrieve all image urls from the images located inside a given
+ * HTML element
+ * @param {Node} elem
+ * @param {String[]} urls Urls will be added here (no duplicates)
+ */
+ function filterImageUrls(elem, urls)
+ {
+ var child = elem.firstChild;
+ while (child) {
+ if (child.tagName == 'IMG') {
+ var url = child.src;
+ if (urls.indexOf(url) == -1) {
+ urls.push(url);
+ }
+ }
+
+ filterImageUrls(child, urls);
+
+ child = child.nextSibling;
+ }
+ }
+
+ return {
+ 'isLoaded': isLoaded,
+ 'isLoading': isLoading,
+ 'load': load,
+ 'loadAll': loadAll,
+ 'filterImageUrls': filterImageUrls
+ };
+})();
+
+
+/** ------------------------------------------------------------------------ **/
+
+
+/**
+ * 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
+ */
+links.Timeline.addEventListener = function (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
+ */
+links.Timeline.removeEventListener = function (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
+ */
+links.Timeline.getTarget = function (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
+ */
+links.Timeline.stopPropagation = function (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.
+ */
+links.Timeline.preventDefault = function (event) {
+ if (!event) {
+ event = window.event; }
+
+ if (event.preventDefault) {
+ event.preventDefault(); // non-IE browsers
+ } else {
+ event.returnValue = false; // IE browsers
+ }
+};
+
+
+/**
+ * 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.
+ */
+links.Timeline.getAbsoluteLeft = function (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.
+ */
+links.Timeline.getAbsoluteTop = function (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
+ */
+links.Timeline.getPageY = function (event) {
+ if (('targetTouches' in event) && event.targetTouches.length) {
+ event = event.targetTouches[0];
+ }
+
+ if ('pageY' in event) {
+ return event.pageY;
+ }
+
+ // calculate pageY from clientY
+ var 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
+ */
+links.Timeline.getPageX = function (event) {
+ if (('targetTouches' in event) && event.targetTouches.length) {
+ event = event.targetTouches[0];
+ }
+
+ if ('pageX' in event) {
+ return event.pageX;
+ }
+
+ // calculate pageX from clientX
+ var 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 );
+};
+
+/**
+ * Adds one or more className's to the given elements style
+ * @param {Element} elem
+ * @param {String} className
+ */
+links.Timeline.addClassName = function (elem, className) {
+ var classes = elem.className.split(' ');
+ var classesToAdd = className.split(' ');
+
+ var added = false;
+ for (var i=0; i
+
+
+
+
+
\ No newline at end of file
diff --git a/view/adminhtml/web/template/schedule/status.html b/view/adminhtml/web/template/schedule/status.html
new file mode 100644
index 0000000..3e0c5c5
--- /dev/null
+++ b/view/adminhtml/web/template/schedule/status.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/frontend/email/send-cronscheduler-email.html b/view/frontend/email/send-cronscheduler-email.html
new file mode 100644
index 0000000..ab91b94
--- /dev/null
+++ b/view/frontend/email/send-cronscheduler-email.html
@@ -0,0 +1,7 @@
+
+
+{{template config_path="design/email/header_template"}}
+
+{{layout handle="email_cronscheduler_list" items=$items area="frontend"}}
+
+{{template config_path="design/email/footer_template"}}
\ No newline at end of file
diff --git a/view/frontend/layout/email_cronscheduler_list.xml b/view/frontend/layout/email_cronscheduler_list.xml
new file mode 100644
index 0000000..15e7dfb
--- /dev/null
+++ b/view/frontend/layout/email_cronscheduler_list.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/view/frontend/templates/email/jobstatus.phtml b/view/frontend/templates/email/jobstatus.phtml
new file mode 100644
index 0000000..ea547ed
--- /dev/null
+++ b/view/frontend/templates/email/jobstatus.phtml
@@ -0,0 +1,62 @@
+
+getItems() ?>
+getData())) { ?>
+