From f4d5e21daba405d17ef9a170c38b64a726aeb5f3 Mon Sep 17 00:00:00 2001 From: Rosiak Date: Mon, 8 Feb 2016 22:15:24 +0100 Subject: [PATCH 01/29] Make Global Search Limit Configurable #2557 --- html/ajax_search.php | 29 +++++++++++++++-------------- includes/defaults.inc.php | 4 ++++ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/html/ajax_search.php b/html/ajax_search.php index cb8d012a3..77a65190c 100644 --- a/html/ajax_search.php +++ b/html/ajax_search.php @@ -16,6 +16,7 @@ if (!$_SESSION['authenticated']) { $device = array(); $ports = array(); $bgp = array(); +$limit = $config['global_search_result_limit']; if (isset($_REQUEST['search'])) { $search = mres($_REQUEST['search']); @@ -49,10 +50,10 @@ if (isset($_REQUEST['search'])) { else if ($_REQUEST['type'] == 'device') { // Device search if (is_admin() === true || is_read() === true) { - $results = dbFetchRows("SELECT * FROM `devices` WHERE `hostname` LIKE '%".$search."%' OR `location` LIKE '%".$search."%' ORDER BY hostname LIMIT 8"); + $results = dbFetchRows("SELECT * FROM `devices` WHERE `hostname` LIKE '%".$search."%' OR `location` LIKE '%".$search."%' ORDER BY hostname LIMIT ".$limit); } else { - $results = dbFetchRows("SELECT * FROM `devices` AS `D`, `devices_perms` AS `P` WHERE `P`.`user_id` = ? AND `P`.`device_id` = `D`.`device_id` AND (`hostname` LIKE '%".$search."%' OR `location` LIKE '%".$search."%') ORDER BY hostname LIMIT 8", array($_SESSION['user_id'])); + $results = dbFetchRows("SELECT * FROM `devices` AS `D`, `devices_perms` AS `P` WHERE `P`.`user_id` = ? AND `P`.`device_id` = `D`.`device_id` AND (`hostname` LIKE '%".$search."%' OR `location` LIKE '%".$search."%') ORDER BY hostname LIMIT ".$limit, array($_SESSION['user_id'])); } if (count($results)) { @@ -102,10 +103,10 @@ if (isset($_REQUEST['search'])) { else if ($_REQUEST['type'] == 'ports') { // Search ports if (is_admin() === true || is_read() === true) { - $results = dbFetchRows("SELECT `ports`.*,`devices`.* FROM `ports` LEFT JOIN `devices` ON `ports`.`device_id` = `devices`.`device_id` WHERE `ifAlias` LIKE '%".$search."%' OR `ifDescr` LIKE '%".$search."%' OR `ifName` LIKE '%".$search."%' ORDER BY ifDescr LIMIT 8"); + $results = dbFetchRows("SELECT `ports`.*,`devices`.* FROM `ports` LEFT JOIN `devices` ON `ports`.`device_id` = `devices`.`device_id` WHERE `ifAlias` LIKE '%".$search."%' OR `ifDescr` LIKE '%".$search."%' OR `ifName` LIKE '%".$search."%' ORDER BY ifDescr LIMIT ".$limit); } else { - $results = dbFetchRows("SELECT DISTINCT(`I`.`port_id`), `I`.*, `D`.`hostname` FROM `ports` AS `I`, `devices` AS `D`, `devices_perms` AS `P`, `ports_perms` AS `PP` WHERE ((`P`.`user_id` = ? AND `P`.`device_id` = `D`.`device_id`) OR (`PP`.`user_id` = ? AND `PP`.`port_id` = `I`.`port_id` AND `I`.`device_id` = `D`.`device_id`)) AND `D`.`device_id` = `I`.`device_id` AND (`ifAlias` LIKE '%".$search."%' OR `ifDescr` LIKE '%".$search."%' OR `ifName` LIKE '%".$search."%') ORDER BY ifDescr LIMIT 8", array($_SESSION['user_id'], $_SESSION['user_id'])); + $results = dbFetchRows("SELECT DISTINCT(`I`.`port_id`), `I`.*, `D`.`hostname` FROM `ports` AS `I`, `devices` AS `D`, `devices_perms` AS `P`, `ports_perms` AS `PP` WHERE ((`P`.`user_id` = ? AND `P`.`device_id` = `D`.`device_id`) OR (`PP`.`user_id` = ? AND `PP`.`port_id` = `I`.`port_id` AND `I`.`device_id` = `D`.`device_id`)) AND `D`.`device_id` = `I`.`device_id` AND (`ifAlias` LIKE '%".$search."%' OR `ifDescr` LIKE '%".$search."%' OR `ifName` LIKE '%".$search."%') ORDER BY ifDescr LIMIT ".$limit, array($_SESSION['user_id'], $_SESSION['user_id'])); } if (count($results)) { @@ -154,10 +155,10 @@ if (isset($_REQUEST['search'])) { else if ($_REQUEST['type'] == 'bgp') { // Search bgp peers if (is_admin() === true || is_read() === true) { - $results = dbFetchRows("SELECT `bgpPeers`.*,`devices`.* FROM `bgpPeers` LEFT JOIN `devices` ON `bgpPeers`.`device_id` = `devices`.`device_id` WHERE `astext` LIKE '%".$search."%' OR `bgpPeerIdentifier` LIKE '%".$search."%' OR `bgpPeerRemoteAs` LIKE '%".$search."%' ORDER BY `astext` LIMIT 8"); + $results = dbFetchRows("SELECT `bgpPeers`.*,`devices`.* FROM `bgpPeers` LEFT JOIN `devices` ON `bgpPeers`.`device_id` = `devices`.`device_id` WHERE `astext` LIKE '%".$search."%' OR `bgpPeerIdentifier` LIKE '%".$search."%' OR `bgpPeerRemoteAs` LIKE '%".$search."%' ORDER BY `astext` LIMIT ".$limit); } else { - $results = dbFetchRows("SELECT `bgpPeers`.*,`D`.* FROM `bgpPeers`, `devices` AS `D`, `devices_perms` AS `P` WHERE `P`.`user_id` = ? AND `P`.`device_id` = `D`.`device_id` AND `bgpPeers`.`device_id`=`D`.`device_id` AND (`astext` LIKE '%".$search."%' OR `bgpPeerIdentifier` LIKE '%".$search."%' OR `bgpPeerRemoteAs` LIKE '%".$search."%') ORDER BY `astext` LIMIT 8", array($_SESSION['user_id'])); + $results = dbFetchRows("SELECT `bgpPeers`.*,`D`.* FROM `bgpPeers`, `devices` AS `D`, `devices_perms` AS `P` WHERE `P`.`user_id` = ? AND `P`.`device_id` = `D`.`device_id` AND `bgpPeers`.`device_id`=`D`.`device_id` AND (`astext` LIKE '%".$search."%' OR `bgpPeerIdentifier` LIKE '%".$search."%' OR `bgpPeerRemoteAs` LIKE '%".$search."%') ORDER BY `astext` LIMIT ".$limit, array($_SESSION['user_id'])); } if (count($results)) { @@ -209,10 +210,10 @@ if (isset($_REQUEST['search'])) { else if ($_REQUEST['type'] == 'applications') { // Device search if (is_admin() === true || is_read() === true) { - $results = dbFetchRows("SELECT * FROM `applications` INNER JOIN `devices` ON devices.device_id = applications.device_id WHERE `app_type` LIKE '%".$search."%' OR `hostname` LIKE '%".$search."%' ORDER BY hostname LIMIT 8"); + $results = dbFetchRows("SELECT * FROM `applications` INNER JOIN `devices` ON devices.device_id = applications.device_id WHERE `app_type` LIKE '%".$search."%' OR `hostname` LIKE '%".$search."%' ORDER BY hostname LIMIT ".$limit); } else { - $results = dbFetchRows("SELECT * FROM `applications` INNER JOIN `devices` AS `D` ON `D`.`device_id` = `applications`.`device_id` INNER JOIN `devices_perms` AS `P` ON `P`.`device_id` = `D`.`device_id` WHERE `P`.`user_id` = ? AND (`app_type` LIKE '%".$search."%' OR `hostname` LIKE '%".$search."%') ORDER BY hostname LIMIT 8", array($_SESSION['user_id'])); + $results = dbFetchRows("SELECT * FROM `applications` INNER JOIN `devices` AS `D` ON `D`.`device_id` = `applications`.`device_id` INNER JOIN `devices_perms` AS `P` ON `P`.`device_id` = `D`.`device_id` WHERE `P`.`user_id` = ? AND (`app_type` LIKE '%".$search."%' OR `hostname` LIKE '%".$search."%') ORDER BY hostname LIMIT ".$limit, array($_SESSION['user_id'])); } if (count($results)) { @@ -255,10 +256,10 @@ if (isset($_REQUEST['search'])) { else if ($_REQUEST['type'] == 'munin') { // Device search if (is_admin() === true || is_read() === true) { - $results = dbFetchRows("SELECT * FROM `munin_plugins` INNER JOIN `devices` ON devices.device_id = munin_plugins.device_id WHERE `mplug_type` LIKE '%".$search."%' OR `mplug_title` LIKE '%".$search."%' OR `hostname` LIKE '%".$search."%' ORDER BY hostname LIMIT 8"); + $results = dbFetchRows("SELECT * FROM `munin_plugins` INNER JOIN `devices` ON devices.device_id = munin_plugins.device_id WHERE `mplug_type` LIKE '%".$search."%' OR `mplug_title` LIKE '%".$search."%' OR `hostname` LIKE '%".$search."%' ORDER BY hostname LIMIT ".$limit); } else { - $results = dbFetchRows("SELECT * FROM `munin_plugins` INNER JOIN `devices` AS `D` ON `D`.`device_id` = `munin_plugins`.`device_id` INNER JOIN `devices_perms` AS `P` ON `P`.`device_id` = `D`.`device_id` WHERE `P`.`user_id` = ? AND (`mplug_type` LIKE '%".$search."%' OR `mplug_title` LIKE '%".$search."%' OR `hostname` LIKE '%".$search."%') ORDER BY hostname LIMIT 8", array($_SESSION['user_id'])); + $results = dbFetchRows("SELECT * FROM `munin_plugins` INNER JOIN `devices` AS `D` ON `D`.`device_id` = `munin_plugins`.`device_id` INNER JOIN `devices_perms` AS `P` ON `P`.`device_id` = `D`.`device_id` WHERE `P`.`user_id` = ? AND (`mplug_type` LIKE '%".$search."%' OR `mplug_title` LIKE '%".$search."%' OR `hostname` LIKE '%".$search."%') ORDER BY hostname LIMIT ".$limit, array($_SESSION['user_id'])); } if (count($results)) { @@ -301,10 +302,10 @@ if (isset($_REQUEST['search'])) { else if ($_REQUEST['type'] == 'iftype') { // Device search if (is_admin() === true || is_read() === true) { - $results = dbFetchRows("SELECT `ports`.ifType FROM `ports` WHERE `ifType` LIKE '%".$search."%' GROUP BY ifType ORDER BY ifType LIMIT 8"); + $results = dbFetchRows("SELECT `ports`.ifType FROM `ports` WHERE `ifType` LIKE '%".$search."%' GROUP BY ifType ORDER BY ifType LIMIT ".$limit); } else { - $results = dbFetchRows("SELECT `I`.ifType FROM `ports` AS `I`, `devices` AS `D`, `devices_perms` AS `P`, `ports_perms` AS `PP` WHERE ((`P`.`user_id` = ? AND `P`.`device_id` = `D`.`device_id`) OR (`PP`.`user_id` = ? AND `PP`.`port_id` = `I`.`port_id` AND `I`.`device_id` = `D`.`device_id`)) AND `D`.`device_id` = `I`.`device_id` AND (`ifType` LIKE '%".$search."%') GROUP BY ifType ORDER BY ifType LIMIT 8", array($_SESSION['user_id'], $_SESSION['user_id'])); + $results = dbFetchRows("SELECT `I`.ifType FROM `ports` AS `I`, `devices` AS `D`, `devices_perms` AS `P`, `ports_perms` AS `PP` WHERE ((`P`.`user_id` = ? AND `P`.`device_id` = `D`.`device_id`) OR (`PP`.`user_id` = ? AND `PP`.`port_id` = `I`.`port_id` AND `I`.`device_id` = `D`.`device_id`)) AND `D`.`device_id` = `I`.`device_id` AND (`ifType` LIKE '%".$search."%') GROUP BY ifType ORDER BY ifType LIMIT ".$limit, array($_SESSION['user_id'], $_SESSION['user_id'])); } if (count($results)) { $found = 1; @@ -323,10 +324,10 @@ if (isset($_REQUEST['search'])) { else if ($_REQUEST['type'] == 'bill') { // Device search if (is_admin() === true || is_read() === true) { - $results = dbFetchRows("SELECT `bills`.bill_id, `bills`.bill_name FROM `bills` WHERE `bill_name` LIKE '%".$search."%' OR `bill_notes` LIKE '%".$search."%' LIMIT 8"); + $results = dbFetchRows("SELECT `bills`.bill_id, `bills`.bill_name FROM `bills` WHERE `bill_name` LIKE '%".$search."%' OR `bill_notes` LIKE '%".$search."%' LIMIT ".$limit); } else { - $results = dbFetchRows("SELECT `bills`.bill_id, `bills`.bill_name FROM `bills` INNER JOIN `bill_perms` ON `bills`.bill_id = `bill_perms`.bill_id WHERE `bill_perms`.user_id = ? AND (`bill_name` LIKE '%".$search."%' OR `bill_notes` LIKE '%".$search."%') LIMIT 8", array($_SESSION['user_id'])); + $results = dbFetchRows("SELECT `bills`.bill_id, `bills`.bill_name FROM `bills` INNER JOIN `bill_perms` ON `bills`.bill_id = `bill_perms`.bill_id WHERE `bill_perms`.user_id = ? AND (`bill_name` LIKE '%".$search."%' OR `bill_notes` LIKE '%".$search."%') LIMIT ".$limit, array($_SESSION['user_id'])); } $json = json_encode($results); die($json); diff --git a/includes/defaults.inc.php b/includes/defaults.inc.php index bed0b577d..b4c2730d1 100644 --- a/includes/defaults.inc.php +++ b/includes/defaults.inc.php @@ -861,3 +861,7 @@ $config['default_port_association_mode'] = 'ifIndex'; // Ignore ports which can't be mapped using a devices port_association_mode // See include/polling/ports.inc.php for a lenghty explanation. $config['ignore_unmapable_port'] = False; + +// Default Global Search result limit +$config['global_search_result_limit'] = 8; + From 1c630b0b8d372af4e74ff406aa73261b80bf3b10 Mon Sep 17 00:00:00 2001 From: Rosiak Date: Sat, 13 Feb 2016 21:09:45 +0100 Subject: [PATCH 02/29] Move function to WebUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on Neil’s suggestion. --- html/ajax_search.php | 2 +- html/pages/settings/webui.inc.php | 24 ++++++++++++++++++++++++ includes/defaults.inc.php | 3 --- sql-schema/103.sql | 1 + 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 html/pages/settings/webui.inc.php create mode 100644 sql-schema/103.sql diff --git a/html/ajax_search.php b/html/ajax_search.php index 77a65190c..ba9dc79b7 100644 --- a/html/ajax_search.php +++ b/html/ajax_search.php @@ -16,7 +16,7 @@ if (!$_SESSION['authenticated']) { $device = array(); $ports = array(); $bgp = array(); -$limit = $config['global_search_result_limit']; +$limit = $config['webui']['global_search_result_limit']; if (isset($_REQUEST['search'])) { $search = mres($_REQUEST['search']); diff --git a/html/pages/settings/webui.inc.php b/html/pages/settings/webui.inc.php new file mode 100644 index 000000000..a5b6bf7f2 --- /dev/null +++ b/html/pages/settings/webui.inc.php @@ -0,0 +1,24 @@ + 'webui.global_search_result_limit', + 'descr' => 'Set the max search result limit', + 'type' => 'text', + ), +); + +echo ' +
+
+'; + +echo generate_dynamic_config_panel('Search settings',true,$config_groups,$search_conf); + +echo ' +
+
+'; diff --git a/includes/defaults.inc.php b/includes/defaults.inc.php index b4c2730d1..3eefe80a7 100644 --- a/includes/defaults.inc.php +++ b/includes/defaults.inc.php @@ -862,6 +862,3 @@ $config['default_port_association_mode'] = 'ifIndex'; // See include/polling/ports.inc.php for a lenghty explanation. $config['ignore_unmapable_port'] = False; -// Default Global Search result limit -$config['global_search_result_limit'] = 8; - diff --git a/sql-schema/103.sql b/sql-schema/103.sql new file mode 100644 index 000000000..c1f56b5f6 --- /dev/null +++ b/sql-schema/103.sql @@ -0,0 +1 @@ +INSERT INTO `config` (`config_name`,`config_value`,`config_default`,`config_descr`,`config_group`,`config_group_order`,`config_sub_group`,`config_sub_group_order`,`config_hidden`,`config_disabled`) VALUES ('webui.global_search_result_limit','8','8','Global search results limit','webui',0,'search',0,'1','0'); From fd3c7f4fa73e8232767cb38aee528b5a01f3dddf Mon Sep 17 00:00:00 2001 From: Rosiak Date: Sun, 14 Feb 2016 22:47:22 +0100 Subject: [PATCH 03/29] Remove space --- includes/defaults.inc.php | 1 - 1 file changed, 1 deletion(-) diff --git a/includes/defaults.inc.php b/includes/defaults.inc.php index 3eefe80a7..bed0b577d 100644 --- a/includes/defaults.inc.php +++ b/includes/defaults.inc.php @@ -861,4 +861,3 @@ $config['default_port_association_mode'] = 'ifIndex'; // Ignore ports which can't be mapped using a devices port_association_mode // See include/polling/ports.inc.php for a lenghty explanation. $config['ignore_unmapable_port'] = False; - From adedd872aee9c7913289c579b1b9702694af0952 Mon Sep 17 00:00:00 2001 From: pblasquez Date: Tue, 16 Feb 2016 19:46:22 -0800 Subject: [PATCH 04/29] Update graph.php --- html/graph.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/html/graph.php b/html/graph.php index 99c53bd29..a3a7db529 100644 --- a/html/graph.php +++ b/html/graph.php @@ -40,8 +40,9 @@ require_once '../includes/dbFacile.php'; require_once '../includes/rewrites.php'; require_once 'includes/functions.inc.php'; require_once '../includes/rrdtool.inc.php'; -require_once 'includes/authenticate.inc.php'; - +if($config['allow_unauth_graphs'] == 0) { + require_once 'includes/authenticate.inc.php'; +} require 'includes/graphs/graph.inc.php'; $console_color = new Console_Color2(); From 0c2abe5b77d4cd8b79bd7431f8671323214ed0da Mon Sep 17 00:00:00 2001 From: Tony Murray Date: Tue, 16 Feb 2016 22:51:42 -0600 Subject: [PATCH 05/29] Properly parse sysDescr fro Unifi Switches Fix issue #2759 I only tested the regex, not against hardware as I don't have access to any. --- includes/polling/os/edgeswitch.inc.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/includes/polling/os/edgeswitch.inc.php b/includes/polling/os/edgeswitch.inc.php index f2549f960..5ffb02c30 100644 --- a/includes/polling/os/edgeswitch.inc.php +++ b/includes/polling/os/edgeswitch.inc.php @@ -1,6 +1,7 @@ Date: Tue, 16 Feb 2016 23:46:31 -0600 Subject: [PATCH 06/29] Use raw timeticks instead of parsing the string for sysUpTime and hrSystemUptime --- includes/polling/core.inc.php | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/includes/polling/core.inc.php b/includes/polling/core.inc.php index 1af4e3b58..213f95785 100644 --- a/includes/polling/core.inc.php +++ b/includes/polling/core.inc.php @@ -14,7 +14,7 @@ unset($poll_device); -$snmpdata = snmp_get_multi($device, 'sysUpTime.0 sysLocation.0 sysContact.0 sysName.0 sysDescr.0 sysObjectID.0', '-OQnUs', 'SNMPv2-MIB:HOST-RESOURCES-MIB:SNMP-FRAMEWORK-MIB'); +$snmpdata = snmp_get_multi($device, 'sysUpTime.0 sysLocation.0 sysContact.0 sysName.0 sysDescr.0 sysObjectID.0', '-OQnUst', 'SNMPv2-MIB:HOST-RESOURCES-MIB:SNMP-FRAMEWORK-MIB'); $poll_device = $snmpdata[0]; $poll_device['sysName'] = strtolower($poll_device['sysName']); @@ -25,35 +25,20 @@ if (!empty($agent_data['uptime'])) { } if (empty($uptime)) { - $snmp_data = snmp_get_multi($device, 'snmpEngineTime.0 hrSystemUptime.0', '-OQnUs', 'HOST-RESOURCES-MIB:SNMP-FRAMEWORK-MIB'); + $snmp_data = snmp_get_multi($device, 'snmpEngineTime.0 hrSystemUptime.0', '-OQnUst', 'HOST-RESOURCES-MIB:SNMP-FRAMEWORK-MIB'); $uptime_data = $snmp_data[0]; $snmp_uptime = (integer) $uptime_data['snmpEngineTime']; $hrSystemUptime = $uptime_data['hrSystemUptime']; if (!empty($hrSystemUptime) && !strpos($hrSystemUptime, 'No') && ($device['os'] != 'windows')) { - echo 'Using hrSystemUptime ('.$hrSystemUptime.")\n"; - $agent_uptime = $uptime; // Move uptime into agent_uptime - // HOST-RESOURCES-MIB::hrSystemUptime.0 = Timeticks: (63050465) 7 days, 7:08:24.65 - $hrSystemUptime = str_replace('(', '', $hrSystemUptime); - $hrSystemUptime = str_replace(')', '', $hrSystemUptime); - list($days,$hours, $mins, $secs) = explode(':', $hrSystemUptime); - list($secs, $microsecs) = explode('.', $secs); - $hours = ($hours + ($days * 24)); - $mins = ($mins + ($hours * 60)); - $secs = ($secs + ($mins * 60)); - $uptime = $secs; + $agent_uptime = $uptime; + + $uptime = floor($hrSystemUptime / 100); + echo 'Using hrSystemUptime ('.$uptime."s)\n"; } else { - echo 'Using SNMP Agent Uptime ('.$poll_device['sysUpTime'].")\n"; - // SNMPv2-MIB::sysUpTime.0 = Timeticks: (2542831) 7:03:48.31 - $poll_device['sysUpTime'] = str_replace('(', '', $poll_device['sysUpTime']); - $poll_device['sysUpTime'] = str_replace(')', '', $poll_device['sysUpTime']); - list($days, $hours, $mins, $secs) = explode(':', $poll_device['sysUpTime']); - list($secs, $microsecs) = explode('.', $secs); - $hours = ($hours + ($days * 24)); - $mins = ($mins + ($hours * 60)); - $secs = ($secs + ($mins * 60)); - $uptime = $secs; + $uptime = floor($poll_device['sysUpTime'] / 100); + echo 'Using SNMP Agent Uptime ('.$uptime."s)\n"; }//end if }//end if From 371f561b9e09bd8f27d96c55dd1cafca603e1a5e Mon Sep 17 00:00:00 2001 From: laf Date: Wed, 17 Feb 2016 13:31:28 +0000 Subject: [PATCH 07/29] Removed content-type --- html/ajax_form.php | 1 - 1 file changed, 1 deletion(-) diff --git a/html/ajax_form.php b/html/ajax_form.php index 3851d5b4f..999a43a4d 100644 --- a/html/ajax_form.php +++ b/html/ajax_form.php @@ -29,7 +29,6 @@ if (!$_SESSION['authenticated']) { if (preg_match('/^[a-zA-Z0-9\-]+$/', $_POST['type']) == 1) { if (file_exists('includes/forms/'.$_POST['type'].'.inc.php')) { - header('Content-type: application/json'); include_once 'includes/forms/'.$_POST['type'].'.inc.php'; } } From 731e43bc665ba3c5456c4de4492e61ddff2e6c93 Mon Sep 17 00:00:00 2001 From: Daniel Preussker Date: Wed, 17 Feb 2016 13:59:58 +0000 Subject: [PATCH 08/29] Revert "[WIP] Sanity!" --- alerts.php | 4 +- html/ajax_dash.php | 2 +- html/includes/dev-overview-data.inc.php | 4 +- html/includes/print-alert-rules.php | 2 +- html/includes/print-alerts.inc.php | 2 +- html/includes/print-event-short.inc.php | 2 +- html/includes/print-event.inc.php | 2 +- html/includes/print-syslog.inc.php | 4 +- html/includes/reports/alert-log.pdf.inc.php | 2 +- html/includes/table/alertlog.inc.php | 2 +- html/includes/table/eventlog.inc.php | 2 +- html/includes/table/syslog.inc.php | 2 +- html/includes/vars.inc.php | 3 - html/index.php | 8 +++ html/pages/adduser.inc.php | 2 +- html/pages/alert-log.inc.php | 6 +- html/pages/api-access.inc.php | 8 +-- html/pages/bills/search.inc.php | 2 +- html/pages/delhost.inc.php | 12 ++-- html/pages/device/edit.inc.php | 2 +- html/pages/device/logs/eventlog.inc.php | 2 +- html/pages/device/logs/syslog.inc.php | 2 +- html/pages/devices.inc.php | 2 +- html/pages/inventory.inc.php | 16 +++--- html/pages/routing/vrf.inc.php | 62 ++++++++++----------- html/pages/search.inc.php | 2 +- html/pages/search/arp.inc.php | 8 +-- html/pages/search/ipv4.inc.php | 8 +-- html/pages/search/ipv6.inc.php | 8 +-- html/pages/search/mac.inc.php | 8 +-- html/pages/search/packages.inc.php | 30 +++++----- html/pages/syslog.inc.php | 8 +-- includes/alerts.inc.php | 1 - includes/common.php | 13 ----- includes/dbFacile.mysql.php | 4 -- includes/dbFacile.mysqli.php | 4 -- includes/object-cache.inc.php | 1 - 37 files changed, 117 insertions(+), 135 deletions(-) diff --git a/alerts.php b/alerts.php index 1035214c0..6d791d213 100755 --- a/alerts.php +++ b/alerts.php @@ -182,7 +182,7 @@ function RunFollowUp() { } $alert['details'] = json_decode(gzuncompress($alert['details']), true); - $rextra = json_decode(htmlspecialchars_decode($alert['extra']), true); + $rextra = json_decode($alert['extra'], true); if ($rextra['invert']) { continue; } @@ -237,7 +237,7 @@ function RunAlerts() { $noiss = false; $noacc = false; $updet = false; - $rextra = json_decode(htmlspecialchars_decode($alert['extra']), true); + $rextra = json_decode($alert['extra'], true); $chk = dbFetchRow('SELECT alerts.alerted,devices.ignore,devices.disabled FROM alerts,devices WHERE alerts.device_id = ? && devices.device_id = alerts.device_id && alerts.rule_id = ?', array($alert['device_id'], $alert['rule_id'])); if ($chk['alerted'] == $alert['state']) { $noiss = true; diff --git a/html/ajax_dash.php b/html/ajax_dash.php index e5622c732..0ffc58067 100644 --- a/html/ajax_dash.php +++ b/html/ajax_dash.php @@ -39,7 +39,7 @@ elseif (is_file('includes/common/'.$type.'.inc.php')) { $title = ucfirst($type); $unique_id = str_replace(array("-","."),"_",uniqid($type,true)); $widget_id = mres($_POST['id']); - $widget_settings = json_decode(htmlspecialchars_decode(dbFetchCell('select settings from users_widgets where user_widget_id = ?',array($widget_id))),true); + $widget_settings = json_decode(dbFetchCell('select settings from users_widgets where user_widget_id = ?',array($widget_id)),true); $widget_dimensions = $_POST['dimensions']; if( !empty($_POST['settings']) ) { define('show_settings',true); diff --git a/html/includes/dev-overview-data.inc.php b/html/includes/dev-overview-data.inc.php index defeb6d44..04c4de3dd 100644 --- a/html/includes/dev-overview-data.inc.php +++ b/html/includes/dev-overview-data.inc.php @@ -68,14 +68,14 @@ if ($device['sysContact']) { Contact'; if (get_dev_attrib($device, 'override_sysContact_bool')) { echo ' - '.get_dev_attrib($device, 'override_sysContact_string').' + '.htmlspecialchars(get_dev_attrib($device, 'override_sysContact_string')).' SNMP Contact'; } echo ' - '.$device['sysContact'].' + '.htmlspecialchars($device['sysContact']).' '; } diff --git a/html/includes/print-alert-rules.php b/html/includes/print-alert-rules.php index 4a198d9a1..eac1ae6fd 100644 --- a/html/includes/print-alert-rules.php +++ b/html/includes/print-alert-rules.php @@ -205,7 +205,7 @@ foreach (dbFetchRows($full_query, $param) as $rule) { echo 'Inverted '; } - echo ''.$rule['rule'].''; + echo ''.htmlentities($rule['rule']).''; echo ''.$rule['severity'].''; echo " "; if ($rule_extra['mute'] === true) { diff --git a/html/includes/print-alerts.inc.php b/html/includes/print-alerts.inc.php index ac75278fb..d9758a0f3 100644 --- a/html/includes/print-alerts.inc.php +++ b/html/includes/print-alerts.inc.php @@ -15,7 +15,7 @@ if (!isset($alert_entry['device'])) { '; } -echo ''.$alert_entry['name'].''; +echo ''.htmlspecialchars($alert_entry['name']).''; if ($alert_state != '') { if ($alert_state == '0') { diff --git a/html/includes/print-event-short.inc.php b/html/includes/print-event-short.inc.php index 2c02b7727..42fc75768 100644 --- a/html/includes/print-event-short.inc.php +++ b/html/includes/print-event-short.inc.php @@ -25,6 +25,6 @@ if ($entry['type'] == 'interface') { $entry['link'] = ''.generate_port_link(getifbyid($entry['reference'])).''; } - echo $entry['link'].' '.$entry['message'].' + echo $entry['link'].' '.htmlspecialchars($entry['message']).' '; diff --git a/html/includes/print-event.inc.php b/html/includes/print-event.inc.php index 14b6764d8..d8afd6bfa 100644 --- a/html/includes/print-event.inc.php +++ b/html/includes/print-event.inc.php @@ -31,5 +31,5 @@ else { echo ''.$entry['link'].''; -echo ''.$entry['message'].' +echo ''.htmlspecialchars($entry['message']).' '; diff --git a/html/includes/print-syslog.inc.php b/html/includes/print-syslog.inc.php index 26a0d5b62..0f1c1c669 100644 --- a/html/includes/print-syslog.inc.php +++ b/html/includes/print-syslog.inc.php @@ -8,10 +8,10 @@ if (device_permitted($entry['device_id'])) { if ($vars['page'] != 'device') { $syslog_output .= ''.$entry['date'].' '.generate_device_link($entry).' - '.$entry['program'].' : '.$entry['msg'].''; + '.$entry['program'].' : '.htmlspecialchars($entry['msg']).''; } else { - $syslog_output .= ''.$entry['date'].'   '.$entry['program'].'   '.$entry['msg'].''; + $syslog_output .= ''.$entry['date'].'   '.$entry['program'].'   '.htmlspecialchars($entry['msg']).''; } $syslog_output .= ''; diff --git a/html/includes/reports/alert-log.pdf.inc.php b/html/includes/reports/alert-log.pdf.inc.php index 13b13526d..d293b474b 100644 --- a/html/includes/reports/alert-log.pdf.inc.php +++ b/html/includes/reports/alert-log.pdf.inc.php @@ -66,7 +66,7 @@ foreach (dbFetchRows($full_query, $param) as $alert_entry) { $data[] = array( $alert_entry['time_logged'], $hostname, - $alert_entry['name'], + htmlspecialchars($alert_entry['name']), $text, ); }//end if diff --git a/html/includes/table/alertlog.inc.php b/html/includes/table/alertlog.inc.php index 3c41acb78..8b4d94d3e 100644 --- a/html/includes/table/alertlog.inc.php +++ b/html/includes/table/alertlog.inc.php @@ -77,7 +77,7 @@ foreach (dbFetchRows($sql, $param) as $alertlog) { 'time_logged' => $alertlog['humandate'], 'details' => '', 'hostname' => '
'.generate_device_link($dev, shorthost($dev['hostname'])).'
'.$fault_detail.'
', - 'alert' => $alertlog['alert'], + 'alert' => htmlspecialchars($alertlog['alert']), 'status' => " $text", ); }//end foreach diff --git a/html/includes/table/eventlog.inc.php b/html/includes/table/eventlog.inc.php index 6390b0d0f..62edbbefd 100644 --- a/html/includes/table/eventlog.inc.php +++ b/html/includes/table/eventlog.inc.php @@ -66,7 +66,7 @@ foreach (dbFetchRows($sql, $param) as $eventlog) { 'datetime' => $eventlog['humandate'], 'hostname' => generate_device_link($dev, shorthost($dev['hostname'])), 'type' => $type, - 'message' => $eventlog['message'], + 'message' => htmlspecialchars($eventlog['message']), ); } diff --git a/html/includes/table/syslog.inc.php b/html/includes/table/syslog.inc.php index bd80e7b5f..987aac65a 100644 --- a/html/includes/table/syslog.inc.php +++ b/html/includes/table/syslog.inc.php @@ -66,7 +66,7 @@ foreach (dbFetchRows($sql, $param) as $syslog) { 'timestamp' => $syslog['date'], 'device_id' => generate_device_link($dev, shorthost($dev['hostname'])), 'program' => $syslog['program'], - 'msg' => $syslog['msg'], + 'msg' => htmlspecialchars($syslog['msg']), ); } diff --git a/html/includes/vars.inc.php b/html/includes/vars.inc.php index 3326a119f..369a13a74 100644 --- a/html/includes/vars.inc.php +++ b/html/includes/vars.inc.php @@ -43,6 +43,3 @@ foreach ($_GET as $name => $value) { foreach ($_POST as $name => $value) { $vars[$name] = $value; } - -array_walk_recursive($vars,'sanitize_array'); -reset($vars); diff --git a/html/index.php b/html/index.php index 494d6c3de..46164c663 100644 --- a/html/index.php +++ b/html/index.php @@ -214,6 +214,14 @@ else {
"); + print_r($_GET); + print_r($vars); + echo(""); +} + if ($_SESSION['authenticated']) { // Authenticated. Print a page. if (isset($vars['page']) && !strstr("..", $vars['page']) && is_file("pages/" . $vars['page'] . ".inc.php")) { diff --git a/html/pages/adduser.inc.php b/html/pages/adduser.inc.php index 7bb5c4838..791d4d03d 100644 --- a/html/pages/adduser.inc.php +++ b/html/pages/adduser.inc.php @@ -27,7 +27,7 @@ else { // FIXME: missing email field here on the form if (adduser($_POST['new_username'], $_POST['new_password'], $_POST['new_level'], $_POST['new_email'], $_POST['new_realname'], $_POST['can_modify_passwd'])) { - echo 'User '.$vars['username'].' added!'; + echo 'User '.$_POST['username'].' added!'; } } else { diff --git a/html/pages/alert-log.inc.php b/html/pages/alert-log.inc.php index 4042b2e3d..752be46ce 100644 --- a/html/pages/alert-log.inc.php +++ b/html/pages/alert-log.inc.php @@ -51,7 +51,7 @@ foreach (get_all_devices() as $hostname) { $device_id = getidbyname($hostname); if (device_permitted($device_id)) { echo '"
- +
+ - +
diff --git a/html/pages/device/edit.inc.php b/html/pages/device/edit.inc.php index 62cd2a4c5..e866e4504 100644 --- a/html/pages/device/edit.inc.php +++ b/html/pages/device/edit.inc.php @@ -58,7 +58,7 @@ else { echo(generate_link($text,$link_array,array('section'=>$type))); -# echo(" " . $text .""); +# echo(" " . $text .""); if ($vars['section'] == $type) { echo(""); } diff --git a/html/pages/device/logs/eventlog.inc.php b/html/pages/device/logs/eventlog.inc.php index f5f0444b6..a5c60de86 100644 --- a/html/pages/device/logs/eventlog.inc.php +++ b/html/pages/device/logs/eventlog.inc.php @@ -2,7 +2,7 @@
- +
+
+ + +

+ This example demonstrates editable groups (for now only reordering). +

+
+ + + + \ No newline at end of file diff --git a/examples/timeline/other/timezone.html b/examples/timeline/other/timezone.html new file mode 100644 index 000000000..fd7a22c00 --- /dev/null +++ b/examples/timeline/other/timezone.html @@ -0,0 +1,80 @@ + + + + Timeline | Time zone + + + + + + + + + +

Time zone

+ +

+ The following demo shows how to display items in local time (default), in UTC, or for a specific time zone offset. By configuring your own moment constructor, you can display items in the time zone that you want. All timelines have the same start and end date. +

+ +

Local time

+
+ +

UTC

+
+ +

UTC +08:00

+
+ + + + + + \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js index 636061b93..af46d93fe 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -100,6 +100,7 @@ gulp.task('bundle-css', ['clean'], function () { var files = [ './lib/shared/activator.css', './lib/shared/bootstrap.css', + './lib/shared/configuration.css', './lib/timeline/component/css/timeline.css', './lib/timeline/component/css/panel.css', @@ -115,9 +116,8 @@ gulp.task('bundle-css', ['clean'], function () { './lib/timeline/component/css/pathStyles.css', './lib/network/css/network-manipulation.css', - './lib/network/css/network-navigation.css', './lib/network/css/network-tooltip.css', - './lib/network/css/network-configuration.css', + './lib/network/css/network-navigation.css', './lib/network/css/network-colorpicker.css' ]; @@ -132,13 +132,13 @@ gulp.task('bundle-css', ['clean'], function () { }); gulp.task('copy', ['clean'], function () { - var network = gulp.src('./lib/network/img/**/*') + var network = gulp.src('./lib/network/img/**/*') .pipe(gulp.dest(DIST + '/img/network')); - var timeline = gulp.src('./lib/timeline/img/**/*') + var timeline = gulp.src('./lib/timeline/img/**/*') .pipe(gulp.dest(DIST + '/img/timeline')); - return merge(network, timeline); + return merge(network, timeline); }); gulp.task('minify', ['bundle-js'], function (cb) { @@ -148,7 +148,7 @@ gulp.task('minify', ['bundle-js'], function (cb) { // any issues when concatenating the file downstream (the file ends // with a comment). fs.writeFileSync(DIST + '/' + VIS_MIN_JS, result.code + '\n'); - fs.writeFileSync(DIST + '/' + VIS_MAP, result.map); + fs.writeFileSync(DIST + '/' + VIS_MAP, result.map.replace(/"\.\/dist\//g, '"')); cb(); }); diff --git a/index.js b/index.js index 71ef93389..d84e82e8d 100644 --- a/index.js +++ b/index.js @@ -22,8 +22,8 @@ exports.graph3d = { exports.Timeline = require('./lib/timeline/Timeline'); exports.Graph2d = require('./lib/timeline/Graph2d'); exports.timeline = { + Core: require('./lib/timeline/Core'), DateUtil: require('./lib/timeline/DateUtil'), - DataStep: require('./lib/timeline/DataStep'), Range: require('./lib/timeline/Range'), stack: require('./lib/timeline/Stack'), TimeStep: require('./lib/timeline/TimeStep'), @@ -37,13 +37,14 @@ exports.timeline = { RangeItem: require('./lib/timeline/component/item/RangeItem') }, + BackgroundGroup: require('./lib/timeline/component/BackgroundGroup'), Component: require('./lib/timeline/component/Component'), CurrentTime: require('./lib/timeline/component/CurrentTime'), CustomTime: require('./lib/timeline/component/CustomTime'), DataAxis: require('./lib/timeline/component/DataAxis'), + DataScale: require('./lib/timeline/component/DataScale'), GraphGroup: require('./lib/timeline/component/GraphGroup'), Group: require('./lib/timeline/component/Group'), - BackgroundGroup: require('./lib/timeline/component/BackgroundGroup'), ItemSet: require('./lib/timeline/component/ItemSet'), Legend: require('./lib/timeline/component/Legend'), LineGraph: require('./lib/timeline/component/LineGraph'), @@ -62,13 +63,7 @@ exports.network = { exports.network.convertDot = function (input) {return exports.network.dotparser.DOTToGraph(input)}; exports.network.convertGephi = function (input,options) {return exports.network.gephiParser.parseGephi(input,options)}; -// Deprecated since v3.0.0 -exports.Graph = function () { - throw new Error('Graph is renamed to Network. Please create a graph as new vis.Network(...)'); -}; - // bundled external libraries exports.moment = require('./lib/module/moment'); -exports.hammer = require('./lib/module/hammer'); // TODO: deprecate exports.hammer some day exports.Hammer = require('./lib/module/hammer'); exports.keycharm = require('keycharm'); \ No newline at end of file diff --git a/lib/DOMutil.js b/lib/DOMutil.js index ebedeb21b..1e778bc0f 100644 --- a/lib/DOMutil.js +++ b/lib/DOMutil.js @@ -36,6 +36,16 @@ exports.cleanupElements = function(JSONcontainer) { } }; +/** + * Ensures that all elements are removed first up so they can be recreated cleanly + * @param JSONcontainer + */ +exports.resetElements = function(JSONcontainer) { + exports.prepareElements(JSONcontainer); + exports.cleanupElements(JSONcontainer); + exports.prepareElements(JSONcontainer); +} + /** * Allocate or generate an SVG element if needed. Store a reference to it in the JSON container and draw it in the svgContainer * the JSON container and the SVG container have to be supplied so other svg containers (like the legend) can use this. @@ -149,8 +159,8 @@ exports.drawPoint = function(x, y, groupTemplate, JSONcontainer, svgContainer, l point.setAttributeNS(null, "height", groupTemplate.size); } - if (groupTemplate.style !== undefined) { - point.setAttributeNS(null, "style", groupTemplate.style); + if (groupTemplate.styles !== undefined) { + point.setAttributeNS(null, "style", groupTemplate.styles); } point.setAttributeNS(null, "class", groupTemplate.className + " vis-point"); //handle label diff --git a/lib/DataSet.js b/lib/DataSet.js index e8d1c973c..fbc6b92a0 100644 --- a/lib/DataSet.js +++ b/lib/DataSet.js @@ -60,15 +60,15 @@ function DataSet (data, options) { // all variants of a Date are internally stored as Date, so we can convert // from everything to everything (also from ISODate to Number for example) if (this._options.type) { - for (var field in this._options.type) { - if (this._options.type.hasOwnProperty(field)) { - var value = this._options.type[field]; - if (value == 'Date' || value == 'ISODate' || value == 'ASPDate') { - this._type[field] = 'Date'; - } - else { - this._type[field] = value; - } + var fields = Object.keys(this._options.type); + for (var i = 0, len = fields.length; i < len; i++) { + var field = fields[i]; + var value = this._options.type[field]; + if (value == 'Date' || value == 'ISODate' || value == 'ASPDate') { + this._type[field] = 'Date'; + } + else { + this._type[field] = value; } } } @@ -184,7 +184,7 @@ DataSet.prototype._trigger = function (event, params, senderId) { subscribers = subscribers.concat(this._subscribers['*']); } - for (var i = 0; i < subscribers.length; i++) { + for (var i = 0, len = subscribers.length; i < len; i++) { var subscriber = subscribers[i]; if (subscriber.callback) { subscriber.callback(event, params, senderId || null); @@ -236,6 +236,7 @@ DataSet.prototype.add = function (data, senderId) { DataSet.prototype.update = function (data, senderId) { var addedIds = []; var updatedIds = []; + var oldData = []; var updatedData = []; var me = this; var fieldId = me._fieldId; @@ -243,10 +244,12 @@ DataSet.prototype.update = function (data, senderId) { var addOrUpdate = function (item) { var id = item[fieldId]; if (me._data[id]) { + var oldItem = util.extend({}, me._data[id]); // update item id = me._updateItem(item); updatedIds.push(id); updatedData.push(item); + oldData.push(oldItem); } else { // add new item @@ -258,7 +261,11 @@ DataSet.prototype.update = function (data, senderId) { if (Array.isArray(data)) { // Array for (var i = 0, len = data.length; i < len; i++) { - addOrUpdate(data[i]); + if (data[i] instanceof Object){ + addOrUpdate(data[i]); + } else { + console.warn('Ignoring input item, which is not an object at index ' + i); + } } } else if (data instanceof Object) { @@ -273,7 +280,15 @@ DataSet.prototype.update = function (data, senderId) { this._trigger('add', {items: addedIds}, senderId); } if (updatedIds.length) { - this._trigger('update', {items: updatedIds, data: updatedData}, senderId); + var props = { items: updatedIds, oldData: oldData, data: updatedData }; + // TODO: remove deprecated property 'data' some day + //Object.defineProperty(props, 'data', { + // 'get': (function() { + // console.warn('Property data is deprecated. Use DataSet.get(ids) to retrieve the new data, use the oldData property on this object to get the old data'); + // return updatedData; + // }).bind(this) + //}); + this._trigger('update', props, senderId); } return addedIds.concat(updatedIds); @@ -340,13 +355,13 @@ DataSet.prototype.get = function (args) { // build options var type = options && options.type || this._options.type; var filter = options && options.filter; - var items = [], item, itemId, i, len; + var items = [], item, itemIds, itemId, i, len; // convert items if (id != undefined) { // return a single item item = me._getItem(id, type); - if (filter && !filter(item)) { + if (item && filter && !filter(item)) { item = null; } } @@ -361,12 +376,12 @@ DataSet.prototype.get = function (args) { } else { // return all items - for (itemId in this._data) { - if (this._data.hasOwnProperty(itemId)) { - item = me._getItem(itemId, type); - if (!filter || filter(item)) { - items.push(item); - } + itemIds = Object.keys(this._data); + for (i = 0, len = itemIds.length; i < len; i++) { + itemId = itemIds[i]; + item = me._getItem(itemId, type); + if (!filter || filter(item)) { + items.push(item); } } } @@ -391,9 +406,11 @@ DataSet.prototype.get = function (args) { // return the results if (returnType == 'Object') { - var result = {}; - for (i = 0; i < items.length; i++) { - result[items[i].id] = items[i]; + var result = {}, + resultant; + for (i = 0, len = items.length; i < len; i++) { + resultant = items[i]; + result[resultant.id] = resultant; } return result; } @@ -422,6 +439,7 @@ DataSet.prototype.getIds = function (options) { filter = options && options.filter, order = options && options.order, type = options && options.type || this._options.type, + itemIds = Object.keys(data), i, len, id, @@ -434,29 +452,27 @@ DataSet.prototype.getIds = function (options) { if (order) { // create ordered list items = []; - for (id in data) { - if (data.hasOwnProperty(id)) { - item = this._getItem(id, type); - if (filter(item)) { - items.push(item); - } + for (i = 0, len = itemIds.length; i < len; i++) { + id = itemIds[i]; + item = this._getItem(id, type); + if (filter(item)) { + items.push(item); } } this._sort(items, order); for (i = 0, len = items.length; i < len; i++) { - ids[i] = items[i][this._fieldId]; + ids.push(items[i][this._fieldId]); } } else { // create unordered list - for (id in data) { - if (data.hasOwnProperty(id)) { - item = this._getItem(id, type); - if (filter(item)) { - ids.push(item[this._fieldId]); - } + for (i = 0, len = itemIds.length; i < len; i++) { + id = itemIds[i]; + item = this._getItem(id, type); + if (filter(item)) { + ids.push(item[this._fieldId]); } } } @@ -466,25 +482,23 @@ DataSet.prototype.getIds = function (options) { if (order) { // create an ordered list items = []; - for (id in data) { - if (data.hasOwnProperty(id)) { - items.push(data[id]); - } + for (i = 0, len = itemIds.length; i < len; i++) { + id = itemIds[i]; + items.push(data[id]); } this._sort(items, order); for (i = 0, len = items.length; i < len; i++) { - ids[i] = items[i][this._fieldId]; + ids.push(items[i][this._fieldId]); } } else { // create unordered list - for (id in data) { - if (data.hasOwnProperty(id)) { - item = data[id]; - ids.push(item[this._fieldId]); - } + for (i = 0, len = itemIds.length; i < len; i++) { + id = itemIds[i]; + item = data[id]; + ids.push(item[this._fieldId]); } } } @@ -514,6 +528,9 @@ DataSet.prototype.forEach = function (callback, options) { var filter = options && options.filter, type = options && options.type || this._options.type, data = this._data, + itemIds = Object.keys(data), + i, + len, item, id; @@ -521,7 +538,7 @@ DataSet.prototype.forEach = function (callback, options) { // execute forEach on ordered list var items = this.get(options); - for (var i = 0, len = items.length; i < len; i++) { + for (i = 0, len = items.length; i < len; i++) { item = items[i]; id = item[this._fieldId]; callback(item, id); @@ -529,12 +546,11 @@ DataSet.prototype.forEach = function (callback, options) { } else { // unordered - for (id in data) { - if (data.hasOwnProperty(id)) { - item = this._getItem(id, type); - if (!filter || filter(item)) { - callback(item, id); - } + for (i = 0, len = itemIds.length; i < len; i++) { + id = itemIds[i]; + item = this._getItem(id, type); + if (!filter || filter(item)) { + callback(item, id); } } } @@ -556,15 +572,18 @@ DataSet.prototype.map = function (callback, options) { type = options && options.type || this._options.type, mappedItems = [], data = this._data, + itemIds = Object.keys(data), + i, + len, + id, item; // convert and filter items - for (var id in data) { - if (data.hasOwnProperty(id)) { - item = this._getItem(id, type); - if (!filter || filter(item)) { - mappedItems.push(callback(item, id)); - } + for (i = 0, len = itemIds.length; i < len; i++) { + id = itemIds[i]; + item = this._getItem(id, type); + if (!filter || filter(item)) { + mappedItems.push(callback(item, id)); } } @@ -588,17 +607,23 @@ DataSet.prototype._filterFields = function (item, fields) { return item; } - var filteredItem = {}; + var filteredItem = {}, + itemFields = Object.keys(item), + len = itemFields.length, + i, + field; if(Array.isArray(fields)){ - for (var field in item) { - if (item.hasOwnProperty(field) && (fields.indexOf(field) != -1)) { + for (i = 0; i < len; i++) { + field = itemFields[i]; + if (fields.indexOf(field) != -1) { filteredItem[field] = item[field]; } } }else{ - for (var field in item) { - if (item.hasOwnProperty(field) && fields.hasOwnProperty(field)) { + for (i = 0; i < len; i++) { + field = itemFields[i]; + if (fields.hasOwnProperty(field)) { filteredItem[fields[field]] = item[field]; } } @@ -683,7 +708,7 @@ DataSet.prototype._remove = function (id) { } else if (id instanceof Object) { var itemId = id[this._fieldId]; - if (itemId && this._data[itemId]) { + if (itemId !== undefined && this._data[itemId]) { delete this._data[itemId]; this.length--; return itemId; @@ -715,17 +740,19 @@ DataSet.prototype.clear = function (senderId) { */ DataSet.prototype.max = function (field) { var data = this._data, + itemIds = Object.keys(data), max = null, - maxField = null; + maxField = null, + i, + len; - for (var id in data) { - if (data.hasOwnProperty(id)) { - var item = data[id]; - var itemField = item[field]; - if (itemField != null && (!max || itemField > maxField)) { - max = item; - maxField = itemField; - } + for (i = 0, len = itemIds.length; i < len; i++) { + var id = itemIds[i]; + var item = data[id]; + var itemField = item[field]; + if (itemField != null && (!max || itemField > maxField)) { + max = item; + maxField = itemField; } } @@ -739,17 +766,19 @@ DataSet.prototype.max = function (field) { */ DataSet.prototype.min = function (field) { var data = this._data, + itemIds = Object.keys(data), min = null, - minField = null; + minField = null, + i, + len; - for (var id in data) { - if (data.hasOwnProperty(id)) { - var item = data[id]; - var itemField = item[field]; - if (itemField != null && (!min || itemField < minField)) { - min = item; - minField = itemField; - } + for (i = 0, len = itemIds.length; i < len; i++) { + var id = itemIds[i]; + var item = data[id]; + var itemField = item[field]; + if (itemField != null && (!min || itemField < minField)) { + min = item; + minField = itemField; } } @@ -765,31 +794,33 @@ DataSet.prototype.min = function (field) { */ DataSet.prototype.distinct = function (field) { var data = this._data; + var itemIds = Object.keys(data); var values = []; var fieldType = this._options.type && this._options.type[field] || null; var count = 0; - var i; + var i, + j, + len; - for (var prop in data) { - if (data.hasOwnProperty(prop)) { - var item = data[prop]; - var value = item[field]; - var exists = false; - for (i = 0; i < count; i++) { - if (values[i] == value) { - exists = true; - break; - } - } - if (!exists && (value !== undefined)) { - values[count] = value; - count++; + for (i = 0, len = itemIds.length; i < len; i++) { + var id = itemIds[i]; + var item = data[id]; + var value = item[field]; + var exists = false; + for (j = 0; j < count; j++) { + if (values[j] == value) { + exists = true; + break; } } + if (!exists && (value !== undefined)) { + values[count] = value; + count++; + } } if (fieldType) { - for (i = 0; i < values.length; i++) { + for (i = 0, len = values.length; i < len; i++) { values[i] = util.convert(values[i], fieldType); } } @@ -819,12 +850,14 @@ DataSet.prototype._addItem = function (item) { item[this._fieldId] = id; } - var d = {}; - for (var field in item) { - if (item.hasOwnProperty(field)) { - var fieldType = this._type[field]; // type may be undefined - d[field] = util.convert(item[field], fieldType); - } + var d = {}, + fields = Object.keys(item), + i, + len; + for (i = 0, len = fields.length; i < len; i++) { + var field = fields[i]; + var fieldType = this._type[field]; // type may be undefined + d[field] = util.convert(item[field], fieldType); } this._data[id] = d; this.length++; @@ -840,7 +873,7 @@ DataSet.prototype._addItem = function (item) { * @private */ DataSet.prototype._getItem = function (id, types) { - var field, value; + var field, value, i, len; // get the item from the dataset var raw = this._data[id]; @@ -849,22 +882,22 @@ DataSet.prototype._getItem = function (id, types) { } // convert the items field types - var converted = {}; + var converted = {}, + fields = Object.keys(raw); + if (types) { - for (field in raw) { - if (raw.hasOwnProperty(field)) { - value = raw[field]; - converted[field] = util.convert(value, types[field]); - } + for (i = 0, len = fields.length; i < len; i++) { + field = fields[i]; + value = raw[field]; + converted[field] = util.convert(value, types[field]); } } else { // no field types specified, no converting needed - for (field in raw) { - if (raw.hasOwnProperty(field)) { - value = raw[field]; - converted[field] = value; - } + for (i = 0, len = fields.length; i < len; i++) { + field = fields[i]; + value = raw[field]; + converted[field] = value; } } return converted; @@ -890,11 +923,11 @@ DataSet.prototype._updateItem = function (item) { } // merge with current item - for (var field in item) { - if (item.hasOwnProperty(field)) { - var fieldType = this._type[field]; // type may be undefined - d[field] = util.convert(item[field], fieldType); - } + var fields = Object.keys(item); + for (var i = 0, len = fields.length; i < len; i++) { + var field = fields[i]; + var fieldType = this._type[field]; // type may be undefined + d[field] = util.convert(item[field], fieldType); } return id; diff --git a/lib/DataView.js b/lib/DataView.js index 39da84ab3..32a7325ab 100644 --- a/lib/DataView.js +++ b/lib/DataView.js @@ -35,7 +35,7 @@ function DataView (data, options) { * @param {DataSet | DataView} data */ DataView.prototype.setData = function (data) { - var ids, i, len; + var ids, id, i, len; if (this._data) { // unsubscribe from current dataset @@ -44,12 +44,7 @@ DataView.prototype.setData = function (data) { } // trigger a remove of all items in memory - ids = []; - for (var id in this._ids) { - if (this._ids.hasOwnProperty(id)) { - ids.push(id); - } - } + ids = Object.keys(this._ids); this._ids = {}; this.length = 0; this._trigger('remove', {items: ids}); @@ -84,34 +79,34 @@ DataView.prototype.setData = function (data) { * containing a variable parameter. */ DataView.prototype.refresh = function () { - var id; + var id, i, len; var ids = this._data.getIds({filter: this._options && this._options.filter}); + var oldIds = Object.keys(this._ids); var newIds = {}; var added = []; var removed = []; // check for additions - for (var i = 0; i < ids.length; i++) { + for (i = 0, len = ids.length; i < len; i++) { id = ids[i]; newIds[id] = true; if (!this._ids[id]) { added.push(id); this._ids[id] = true; - this.length++; } } // check for removals - for (id in this._ids) { - if (this._ids.hasOwnProperty(id)) { - if (!newIds[id]) { - removed.push(id); - delete this._ids[id]; - this.length--; - } + for (i = 0, len = oldIds.length; i < len; i++) { + id = oldIds[i]; + if (!newIds[id]) { + removed.push(id); + delete this._ids[id]; } } + this.length += added.length - removed.length; + // trigger events if (added.length) { this._trigger('add', {items: added}); @@ -235,6 +230,49 @@ DataView.prototype.getIds = function (options) { return ids; }; +/** + * Map every item in the dataset. + * @param {function} callback + * @param {Object} [options] Available options: + * {Object.} [type] + * {String[]} [fields] filter fields + * {function} [filter] filter items + * {String | function} [order] Order the items by + * a field name or custom sort function. + * @return {Object[]} mappedItems + */ +DataView.prototype.map = function (callback,options) { + var mappedItems = []; + if (this._data) { + var defaultFilter = this._options.filter; + var filter; + + if (options && options.filter) { + if (defaultFilter) { + filter = function (item) { + return defaultFilter(item) && options.filter(item); + } + } + else { + filter = options.filter; + } + } + else { + filter = defaultFilter; + } + + mappedItems = this._data.map(callback,{ + filter: filter, + order: options && options.order + }); + } + else { + mappedItems = []; + } + + return mappedItems; +}; + /** * Get the DataSet to which this DataView is connected. In case there is a chain * of multiple DataViews, the root DataSet of this chain is returned. diff --git a/lib/graph3d/Graph3d.js b/lib/graph3d/Graph3d.js index 20c7b6b1e..683900795 100644 --- a/lib/graph3d/Graph3d.js +++ b/lib/graph3d/Graph3d.js @@ -96,6 +96,8 @@ function Graph3d(container, data, options) { strokeWidth: 1 // px }; + this.dotSizeRatio = 0.02; // size of the dots as a fraction of the graph width + // create a frame and canvas this.create(); @@ -844,6 +846,8 @@ Graph3d.prototype.setOptions = function (options) { if (options.yValueLabel !== undefined) this.yValueLabel = options.yValueLabel; if (options.zValueLabel !== undefined) this.zValueLabel = options.zValueLabel; + if (options.dotSizeRatio !== undefined) this.dotSizeRatio = options.dotSizeRatio; + if (options.style !== undefined) { var styleNumber = this._getStyleNumber(options.style); if (styleNumber !== -1) { @@ -976,7 +980,7 @@ Graph3d.prototype._redrawLegend = function() { if (this.style === Graph3d.STYLE.DOTCOLOR || this.style === Graph3d.STYLE.DOTSIZE) { - var dotSize = this.frame.clientWidth * 0.02; + var dotSize = this.frame.clientWidth * this.dotSizeRatio; var widthMin, widthMax; if (this.style === Graph3d.STYLE.DOTSIZE) { @@ -1613,7 +1617,7 @@ Graph3d.prototype._redrawDataDot = function() { this.dataPoints.sort(sortDepth); // draw the datapoints as colored circles - var dotSize = this.frame.clientWidth * 0.02; // px + var dotSize = this.frame.clientWidth * this.dotSizeRatio; // px for (i = 0; i < this.dataPoints.length; i++) { var point = this.dataPoints[i]; diff --git a/lib/hammerUtil.js b/lib/hammerUtil.js index 9a2f16151..4e1b79d2a 100644 --- a/lib/hammerUtil.js +++ b/lib/hammerUtil.js @@ -7,23 +7,14 @@ var Hammer = require('./module/hammer'); */ exports.onTouch = function (hammer, callback) { callback.inputHandler = function (event) { - if (event.isFirst && !isTouching) { + if (event.isFirst) { callback(event); - - isTouching = true; - setTimeout(function () { - isTouching = false; - }, 0); } }; hammer.on('hammer.input', callback.inputHandler); }; -// isTouching is true while a touch action is being emitted -// this is a hack to prevent `touch` from being fired twice -var isTouching = false; - /** * Register a release event, taking place after a gesture * @param {Hammer} hammer A hammer instance @@ -31,13 +22,8 @@ var isTouching = false; */ exports.onRelease = function (hammer, callback) { callback.inputHandler = function (event) { - if (event.isFinal && !isReleasing) { + if (event.isFinal) { callback(event); - - isReleasing = true; - setTimeout(function () { - isReleasing = false; - }, 0); } }; @@ -45,11 +31,6 @@ exports.onRelease = function (hammer, callback) { }; -// isReleasing is true while a release action is being emitted -// this is a hack to prevent `release` from being fired twice -var isReleasing = false; - - /** * Unregister a touch event, taking place before a gesture * @param {Hammer} hammer A hammer instance diff --git a/lib/header.js b/lib/header.js index 2c8a3431d..3045a7971 100644 --- a/lib/header.js +++ b/lib/header.js @@ -8,7 +8,7 @@ * @date @@date * * @license - * Copyright (C) 2011-2014 Almende B.V, http://almende.com + * Copyright (C) 2011-2016 Almende B.V, http://almende.com * * Vis.js is dual licensed under both * diff --git a/lib/network/Images.js b/lib/network/Images.js index a7354fae9..464e4585b 100644 --- a/lib/network/Images.js +++ b/lib/network/Images.js @@ -2,71 +2,91 @@ * @class Images * This class loads images and keeps them stored. */ -function Images(callback) { - this.images = {}; - this.imageBroken = {}; - this.callback = callback; - +class Images{ + constructor(callback){ + this.images = {}; + this.imageBroken = {}; + this.callback = callback; + } + + /** + * @param {string} url The Url to cache the image as + * @return {Image} imageToLoadBrokenUrlOn The image object + */ + _addImageToCache (url, imageToCache) { + // IE11 fix -- thanks dponch! + if (imageToCache.width === 0) { + document.body.appendChild(imageToCache); + imageToCache.width = imageToCache.offsetWidth; + imageToCache.height = imageToCache.offsetHeight; + document.body.removeChild(imageToCache); + } + + this.images[url] = imageToCache; + } + + /** + * @param {string} url The original Url that failed to load, if the broken image is successfully loaded it will be added to the cache using this Url as the key so that subsequent requests for this Url will return the broken image + * @param {string} brokenUrl Url the broken image to try and load + * @return {Image} imageToLoadBrokenUrlOn The image object + */ + _tryloadBrokenUrl (url, brokenUrl, imageToLoadBrokenUrlOn) { + //If any of the parameters aren't specified then exit the function because nothing constructive can be done + if (url === undefined || brokenUrl === undefined || imageToLoadBrokenUrlOn === undefined) return; + + //Clear the old subscription to the error event and put a new in place that only handle errors in loading the brokenImageUrl + imageToLoadBrokenUrlOn.onerror = () => { + console.error("Could not load brokenImage:", brokenUrl); + //Add an empty image to the cache so that when subsequent load calls are made for the url we don't try load the image and broken image again + this._addImageToCache(url, new Image()); + }; + + //Set the source of the image to the brokenUrl, this is actually what kicks off the loading of the broken image + imageToLoadBrokenUrlOn.src = brokenUrl; + } + + /** + * @return {Image} imageToRedrawWith The images that will be passed to the callback when it is invoked + */ + _redrawWithImage (imageToRedrawWith) { + if (this.callback) { + this.callback(imageToRedrawWith); + } + } + + /** + * @param {string} url Url of the image + * @param {string} brokenUrl Url of an image to use if the url image is not found + * @return {Image} img The image object + */ + load (url, brokenUrl, id) { + //Try and get the image from the cache, if successful then return the cached image + var cachedImage = this.images[url]; + if (cachedImage) return cachedImage; + + //Create a new image + var img = new Image(); + + //Subscribe to the event that is raised if the image loads successfully + img.onload = () => { + //Add the image to the cache and then request a redraw + this._addImageToCache(url, img); + this._redrawWithImage(img); + }; + + //Subscribe to the event that is raised if the image fails to load + img.onerror = () => { + console.error("Could not load image:", url); + //Try and load the image specified by the brokenUrl using + this._tryloadBrokenUrl(url, brokenUrl, img); + } + + //Set the source of the image to the url, this is actuall what kicks off the loading of the image + img.src = url; + + //Return the new image + return img; + } } -/** - * - * @param {string} url Url of the image - * @param {string} url Url of an image to use if the url image is not found - * @return {Image} img The image object - */ -Images.prototype.load = function(url, brokenUrl, id) { - var img = this.images[url]; // make a pointer - if (img === undefined) { - // create the image - var me = this; - img = new Image(); - img.onload = function () { - // IE11 fix -- thanks dponch! - if (this.width === 0) { - document.body.appendChild(this); - this.width = this.offsetWidth; - this.height = this.offsetHeight; - document.body.removeChild(this); - } - - if (me.callback) { - me.images[url] = img; - me.callback(this); - } - }; - - img.onerror = function () { - if (brokenUrl === undefined) { - console.error("Could not load image:", url); - delete this.src; - if (me.callback) { - me.callback(this); - } - } - else { - if (me.imageBroken[id] && me.imageBroken[id][url] === true) { - console.error("Could not load brokenImage:", brokenUrl); - delete this.src; - if (me.callback) { - me.callback(this); - } - } - else { - console.error("Could not load image:", url); - this.src = brokenUrl; - if (me.imageBroken[id] === undefined) { - me.imageBroken[id] = {}; - } - me.imageBroken[id][url] = true; - } - } - }; - - img.src = url; - } - - return img; -}; - -module.exports = Images; +export default Images; \ No newline at end of file diff --git a/lib/network/Network.js b/lib/network/Network.js index 4a83cc5f3..68d10c76c 100644 --- a/lib/network/Network.js +++ b/lib/network/Network.js @@ -24,11 +24,11 @@ import InteractionHandler from './modules/InteractionHandler'; import SelectionHandler from "./modules/SelectionHandler"; import LayoutEngine from "./modules/LayoutEngine"; import ManipulationSystem from "./modules/ManipulationSystem"; -import Configurator from "./../shared/Configurator"; +import Configurator from "./../shared/Configurator"; import Validator from "./../shared/Validator"; import {printStyle} from "./../shared/Validator"; import {allOptions, configureOptions} from './options.js'; - +import KamadaKawai from "./modules/KamadaKawai.js" /** @@ -92,6 +92,7 @@ function Network(container, data, options) { createEdge: function() {}, getPointer: function() {} }, + modules: {}, view: { scale: 1, translation: {x: 0, y: 0} @@ -119,6 +120,9 @@ function Network(container, data, options) { this.nodesHandler = new NodesHandler(this.body, this.images, this.groups, this.layoutEngine); // Handle adding, deleting and updating of nodes as well as global options this.edgesHandler = new EdgesHandler(this.body, this.images, this.groups); // Handle adding, deleting and updating of edges as well as global options + this.body.modules["kamadaKawai"] = new KamadaKawai(this.body,150,0.05); // Layouting algorithm. + this.body.modules["clustering"] = this.clustering; + // create the DOM elements this.canvas._create(); @@ -140,7 +144,6 @@ Emitter(Network.prototype); */ Network.prototype.setOptions = function (options) { if (options !== undefined) { - let errorFound = Validator.validate(options, allOptions); if (errorFound === true) { console.log('%cErrors have been found in the supplied options object.', printStyle); @@ -243,7 +246,7 @@ Network.prototype._updateVisibleIndices = function () { for (let nodeId in nodes) { if (nodes.hasOwnProperty(nodeId)) { if (nodes[nodeId].options.hidden === false) { - this.body.nodeIndices.push(nodeId); + this.body.nodeIndices.push(nodes[nodeId].id); } } } @@ -251,7 +254,7 @@ Network.prototype._updateVisibleIndices = function () { for (let edgeId in edges) { if (edges.hasOwnProperty(edgeId)) { if (edges[edgeId].options.hidden === false) { - this.body.edgeIndices.push(edgeId); + this.body.edgeIndices.push(edges[edgeId].id); } } } @@ -266,7 +269,6 @@ Network.prototype.bindEventListeners = function () { this.body.emitter.on("_dataChanged", () => { // update shortcut lists this._updateVisibleIndices(); - this.physics.updatePhysicsData(); this.body.emitter.emit("_requestRedraw"); // call the dataUpdated event because the only difference between the two is the updating of the indices this.body.emitter.emit("_dataUpdated"); @@ -332,6 +334,9 @@ Network.prototype.setData = function (data) { // emit change in data this.body.emitter.emit("_dataChanged"); + // emit data loaded + this.body.emitter.emit("_dataLoaded"); + // find a stable position or start animating to a stable position this.body.emitter.emit("initPhysics"); }; @@ -439,12 +444,13 @@ Network.prototype.enableEditMode = function() {return this.manipulation.ena Network.prototype.disableEditMode = function() {return this.manipulation.disableEditMode.apply(this.manipulation,arguments);}; Network.prototype.addNodeMode = function() {return this.manipulation.addNodeMode.apply(this.manipulation,arguments);}; Network.prototype.editNode = function() {return this.manipulation.editNode.apply(this.manipulation,arguments);}; -Network.prototype.editNodeMode = function() {console.log("Depricated: Please use editNode instead of editNodeMode."); return this.manipulation.editNode.apply(this.manipulation,arguments);}; +Network.prototype.editNodeMode = function() {console.log("Deprecated: Please use editNode instead of editNodeMode."); return this.manipulation.editNode.apply(this.manipulation,arguments);}; Network.prototype.addEdgeMode = function() {return this.manipulation.addEdgeMode.apply(this.manipulation,arguments);}; Network.prototype.editEdgeMode = function() {return this.manipulation.editEdgeMode.apply(this.manipulation,arguments);}; Network.prototype.deleteSelected = function() {return this.manipulation.deleteSelected.apply(this.manipulation,arguments);}; Network.prototype.getPositions = function() {return this.nodesHandler.getPositions.apply(this.nodesHandler,arguments);}; Network.prototype.storePositions = function() {return this.nodesHandler.storePositions.apply(this.nodesHandler,arguments);}; +Network.prototype.moveNode = function() {return this.nodesHandler.moveNode.apply(this.nodesHandler,arguments);}; Network.prototype.getBoundingBox = function() {return this.nodesHandler.getBoundingBox.apply(this.nodesHandler,arguments);}; Network.prototype.getConnectedNodes = function(objectId) { if (this.body.nodes[objectId] !== undefined) { @@ -459,6 +465,7 @@ Network.prototype.startSimulation = function() {return this.physics.startSim Network.prototype.stopSimulation = function() {return this.physics.stopSimulation.apply(this.physics,arguments);}; Network.prototype.stabilize = function() {return this.physics.stabilize.apply(this.physics,arguments);}; Network.prototype.getSelection = function() {return this.selectionHandler.getSelection.apply(this.selectionHandler,arguments);}; +Network.prototype.setSelection = function() {return this.selectionHandler.setSelection.apply(this.selectionHandler,arguments);}; Network.prototype.getSelectedNodes = function() {return this.selectionHandler.getSelectedNodes.apply(this.selectionHandler,arguments);}; Network.prototype.getSelectedEdges = function() {return this.selectionHandler.getSelectedEdges.apply(this.selectionHandler,arguments);}; Network.prototype.getNodeAt = function() { @@ -477,7 +484,10 @@ Network.prototype.getEdgeAt = function() { }; Network.prototype.selectNodes = function() {return this.selectionHandler.selectNodes.apply(this.selectionHandler,arguments);}; Network.prototype.selectEdges = function() {return this.selectionHandler.selectEdges.apply(this.selectionHandler,arguments);}; -Network.prototype.unselectAll = function() {return this.selectionHandler.unselectAll.apply(this.selectionHandler,arguments);}; +Network.prototype.unselectAll = function() { + this.selectionHandler.unselectAll.apply(this.selectionHandler,arguments); + this.redraw(); +}; Network.prototype.redraw = function() {return this.renderer.redraw.apply(this.renderer,arguments);}; Network.prototype.getScale = function() {return this.view.getScale.apply(this.view,arguments);}; Network.prototype.getViewPosition = function() {return this.view.getViewPosition.apply(this.view,arguments);}; @@ -493,4 +503,6 @@ Network.prototype.getOptionsFromConfigurator = function() { return options; }; + + module.exports = Network; diff --git a/lib/network/NetworkUtil.js b/lib/network/NetworkUtil.js new file mode 100644 index 000000000..07fa313f3 --- /dev/null +++ b/lib/network/NetworkUtil.js @@ -0,0 +1,96 @@ +let util = require("../util"); +class NetworkUtil { + constructor() {} + + /** + * Find the center position of the network considering the bounding boxes + */ + static getRange(allNodes, specificNodes = []) { + var minY = 1e9, maxY = -1e9, minX = 1e9, maxX = -1e9, node; + if (specificNodes.length > 0) { + for (var i = 0; i < specificNodes.length; i++) { + node = allNodes[specificNodes[i]]; + if (minX > node.shape.boundingBox.left) { + minX = node.shape.boundingBox.left; + } + if (maxX < node.shape.boundingBox.right) { + maxX = node.shape.boundingBox.right; + } + if (minY > node.shape.boundingBox.top) { + minY = node.shape.boundingBox.top; + } // top is negative, bottom is positive + if (maxY < node.shape.boundingBox.bottom) { + maxY = node.shape.boundingBox.bottom; + } // top is negative, bottom is positive + } + } + + if (minX === 1e9 && maxX === -1e9 && minY === 1e9 && maxY === -1e9) { + minY = 0, maxY = 0, minX = 0, maxX = 0; + } + return {minX: minX, maxX: maxX, minY: minY, maxY: maxY}; + } + + /** + * Find the center position of the network + */ + static getRangeCore(allNodes, specificNodes = []) { + var minY = 1e9, maxY = -1e9, minX = 1e9, maxX = -1e9, node; + if (specificNodes.length > 0) { + for (var i = 0; i < specificNodes.length; i++) { + node = allNodes[specificNodes[i]]; + if (minX > node.x) { + minX = node.x; + } + if (maxX < node.x) { + maxX = node.x; + } + if (minY > node.y) { + minY = node.y; + } // top is negative, bottom is positive + if (maxY < node.y) { + maxY = node.y; + } // top is negative, bottom is positive + } + } + + if (minX === 1e9 && maxX === -1e9 && minY === 1e9 && maxY === -1e9) { + minY = 0, maxY = 0, minX = 0, maxX = 0; + } + return {minX: minX, maxX: maxX, minY: minY, maxY: maxY}; + } + + + /** + * @param {object} range = {minX: minX, maxX: maxX, minY: minY, maxY: maxY}; + * @returns {{x: number, y: number}} + */ + static findCenter(range) { + return {x: (0.5 * (range.maxX + range.minX)), + y: (0.5 * (range.maxY + range.minY))}; + } + + + /** + * This returns a clone of the options or options of the edge or node to be used for construction of new edges or check functions for new nodes. + * @param item + * @param type + * @returns {{}} + */ + static cloneOptions(item, type) { + let clonedOptions = {}; + if (type === undefined || type === 'node') { + util.deepExtend(clonedOptions, item.options, true); + clonedOptions.x = item.x; + clonedOptions.y = item.y; + clonedOptions.amountOfConnections = item.edges.length; + } + else { + util.deepExtend(clonedOptions, item.options, true); + } + return clonedOptions; + } + +} + +export default NetworkUtil; \ No newline at end of file diff --git a/lib/network/css/network-colorpicker.css b/lib/network/css/network-colorpicker.css index db1d49c63..07406fa85 100644 --- a/lib/network/css/network-colorpicker.css +++ b/lib/network/css/network-colorpicker.css @@ -1,14 +1,17 @@ div.vis-color-picker { position:absolute; + top: 0px; + left: 30px; margin-top:-140px; margin-left:30px; - width:293px; - height:425px; + width:310px; + height:444px; + z-index: 1; padding: 10px; border-radius:15px; background-color:#ffffff; - display:none; + display: none; box-shadow: rgba(0,0,0,0.5) 0px 0px 10px 0px; } @@ -18,8 +21,8 @@ div.vis-color-picker div.vis-arrow { left:5px; } -div.vis-color-picker div.vis-arrow:after, -div.vis-color-picker div.vis-arrow:before { +div.vis-color-picker div.vis-arrow::after, +div.vis-color-picker div.vis-arrow::before { right: 100%; top: 50%; border: solid transparent; diff --git a/lib/network/css/network-manipulation.css b/lib/network/css/network-manipulation.css index 6014a2a2d..89c1dc2cb 100644 --- a/lib/network/css/network-manipulation.css +++ b/lib/network/css/network-manipulation.css @@ -12,17 +12,18 @@ div.vis-network div.vis-manipulation { background: linear-gradient(to bottom, #ffffff 0%,#fcfcfc 48%,#fafafa 50%,#fcfcfc 100%); /* W3C */ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#fcfcfc',GradientType=0 ); /* IE6-9 */ + padding-top:4px; position: absolute; left: 0; top: 0; width: 100%; - height: 30px; + height: 28px; } div.vis-network div.vis-edit-mode { position:absolute; left: 0; - top: 15px; + top: 5px; height: 30px; } @@ -53,8 +54,7 @@ div.vis-network div.vis-close:hover { div.vis-network div.vis-manipulation div.vis-button, div.vis-network div.vis-edit-mode div.vis-button { - position:relative; - top:-7px; + float:left; font-family: verdana; font-size: 12px; -moz-border-radius: 15px; @@ -63,8 +63,8 @@ div.vis-network div.vis-edit-mode div.vis-button { background-position: 0px 0px; background-repeat:no-repeat; height:24px; - margin: 0px 0px 0px 10px; - vertical-align:middle; + margin-left: 10px; + /*vertical-align:middle;*/ cursor: pointer; padding: 0px 8px 0px 8px; -webkit-touch-callout: none; @@ -130,11 +130,12 @@ div.vis-network div.vis-edit-mode div.vis-label { line-height: 25px; } div.vis-network div.vis-manipulation div.vis-separator-line { + float:left; display:inline-block; width:1px; - height:20px; + height:21px; background-color: #bdbdbd; - margin: 5px 7px 0 15px; + margin: 0px 7px 0 15px; /*top right bottom left*/ } /* TODO: is this redundant? diff --git a/lib/network/gephiParser.js b/lib/network/gephiParser.js index a7deb34e1..124d6b454 100644 --- a/lib/network/gephiParser.js +++ b/lib/network/gephiParser.js @@ -27,6 +27,11 @@ function parseGephi(gephiJSON, optionsObj) { edge['from'] = gEdge.source; edge['to'] = gEdge.target; edge['attributes'] = gEdge.attributes; + edge['label'] = gEdge.label; + edge['title'] = gEdge.attributes !== undefined ? gEdge.attributes.title : undefined; + if (gEdge['type'] === 'Directed') { + edge['arrows'] = 'to'; + } // edge['value'] = gEdge.attributes !== undefined ? gEdge.attributes.Weight : undefined; // edge['width'] = edge['value'] !== undefined ? undefined : edgegEdge.size; if (gEdge.color && options.inheritColor === false) { @@ -44,6 +49,7 @@ function parseGephi(gephiJSON, optionsObj) { node['x'] = gNode.x; node['y'] = gNode.y; node['label'] = gNode.label; + node['title'] = gNode.attributes !== undefined ? gNode.attributes.title : undefined; if (options.nodes.parseColor === true) { node['color'] = gNode.color; } diff --git a/lib/network/locales.js b/lib/network/locales.js index 82bf0c557..ba53f3a42 100644 --- a/lib/network/locales.js +++ b/lib/network/locales.js @@ -17,6 +17,42 @@ exports['en'] = { exports['en_EN'] = exports['en']; exports['en_US'] = exports['en']; +// German +exports['de'] = { + edit: 'Editieren', + del: 'L\u00f6sche Auswahl', + back: 'Zur\u00fcck', + addNode: 'Knoten hinzuf\u00fcgen', + addEdge: 'Kante hinzuf\u00fcgen', + editNode: 'Knoten editieren', + editEdge: 'Kante editieren', + addDescription: 'Klicke auf eine freie Stelle, um einen neuen Knoten zu plazieren.', + edgeDescription: 'Klicke auf einen Knoten und ziehe die Kante zu einem anderen Knoten, um diese zu verbinden.', + editEdgeDescription: 'Klicke auf die Verbindungspunkte und ziehe diese auf einen Knoten, um sie zu verbinden.', + createEdgeError: 'Es ist nicht m\u00f6glich, Kanten mit Clustern zu verbinden.', + deleteClusterError: 'Cluster k\u00f6nnen nicht gel\u00f6scht werden.', + editClusterError: 'Cluster k\u00f6nnen nicht editiert werden.' +}; +exports['de_DE'] = exports['de']; + +// Spanish +exports['es'] = { + edit: 'Editar', + del: 'Eliminar selecci\u00f3n', + back: '\u00c1tras', + addNode: 'A\u00f1adir nodo', + addEdge: 'A\u00f1adir arista', + editNode: 'Editar nodo', + editEdge: 'Editar arista', + addDescription: 'Haga clic en un lugar vac\u00edo para colocar un nuevo nodo.', + edgeDescription: 'Haga clic en un nodo y arrastre la arista hacia otro nodo para conectarlos.', + editEdgeDescription: 'Haga clic en un punto de control y arrastrelo a un nodo para conectarlo.', + createEdgeError: 'No se puede conectar una arista a un grupo.', + deleteClusterError: 'No es posible eliminar grupos.', + editClusterError: 'No es posible editar grupos.' +}; +exports['es_ES'] = exports['es']; + // Dutch exports['nl'] = { edit: 'Wijzigen', diff --git a/lib/network/modules/Canvas.js b/lib/network/modules/Canvas.js index 5589712ef..9abe1f5df 100644 --- a/lib/network/modules/Canvas.js +++ b/lib/network/modules/Canvas.js @@ -16,6 +16,7 @@ class Canvas { this.pixelRatio = 1; this.resizeTimer = undefined; this.resizeFunction = this._onResize.bind(this); + this.cameraState = {}; this.options = {}; this.defaultOptions = { @@ -82,6 +83,58 @@ class Canvas { this.body.emitter.emit("_redraw"); } + /** + * Get and store the cameraState + * @private + */ + _getCameraState(pixelRatio = this.pixelRatio) { + this.cameraState.previousWidth = this.frame.canvas.width / pixelRatio; + this.cameraState.previousHeight = this.frame.canvas.height / pixelRatio; + this.cameraState.scale = this.body.view.scale; + this.cameraState.position = this.DOMtoCanvas({x: 0.5 * this.frame.canvas.width / pixelRatio, y: 0.5 * this.frame.canvas.height / pixelRatio}); + } + + /** + * Set the cameraState + * @private + */ + _setCameraState() { + if (this.cameraState.scale !== undefined && + this.frame.canvas.clientWidth !== 0 && + this.frame.canvas.clientHeight !== 0 && + this.pixelRatio !== 0 && + this.cameraState.previousWidth > 0) { + + let widthRatio = (this.frame.canvas.width / this.pixelRatio) / this.cameraState.previousWidth; + let heightRatio = (this.frame.canvas.height / this.pixelRatio) / this.cameraState.previousHeight; + let newScale = this.cameraState.scale; + + if (widthRatio != 1 && heightRatio != 1) { + newScale = this.cameraState.scale * 0.5 * (widthRatio + heightRatio); + } + else if (widthRatio != 1) { + newScale = this.cameraState.scale * widthRatio; + } + else if (heightRatio != 1) { + newScale = this.cameraState.scale * heightRatio; + } + + this.body.view.scale = newScale; + // this comes from the view module. + var currentViewCenter = this.DOMtoCanvas({ + x: 0.5 * this.frame.canvas.clientWidth, + y: 0.5 * this.frame.canvas.clientHeight + }); + + var distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node + x: currentViewCenter.x - this.cameraState.position.x, + y: currentViewCenter.y - this.cameraState.position.y + }; + this.body.view.translation.x += distanceFromCenter.x * this.body.view.scale; + this.body.view.translation.y += distanceFromCenter.y * this.body.view.scale; + } + } + _prepareValue(value) { if (typeof value === 'number') { return value + 'px'; @@ -94,7 +147,7 @@ class Canvas { return value + 'px'; } } - throw new Error('Could not use the value supplie for width or height:' + value); + throw new Error('Could not use the value supplied for width or height:' + value); } @@ -201,7 +254,18 @@ class Canvas { let oldWidth = this.frame.canvas.width; let oldHeight = this.frame.canvas.height; + // update the pixel ratio + let ctx = this.frame.canvas.getContext("2d"); + let previousRatio = this.pixelRatio; // we cache this because the camera state storage needs the old value + this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio || + ctx.mozBackingStorePixelRatio || + ctx.msBackingStorePixelRatio || + ctx.oBackingStorePixelRatio || + ctx.backingStorePixelRatio || 1); + if (width != this.options.width || height != this.options.height || this.frame.style.width != width || this.frame.style.height != height) { + this._getCameraState(previousRatio); + this.frame.style.width = width; this.frame.style.height = height; @@ -220,6 +284,11 @@ class Canvas { // this would adapt the width of the canvas to the width from 100% if and only if // there is a change. + // store the camera if there is a change in size. + if (this.frame.canvas.width != Math.round(this.frame.canvas.clientWidth * this.pixelRatio) || this.frame.canvas.height != Math.round(this.frame.canvas.clientHeight * this.pixelRatio)) { + this._getCameraState(previousRatio); + } + if (this.frame.canvas.width != Math.round(this.frame.canvas.clientWidth * this.pixelRatio)) { this.frame.canvas.width = Math.round(this.frame.canvas.clientWidth * this.pixelRatio); emitEvent = true; @@ -237,6 +306,9 @@ class Canvas { oldWidth: Math.round(oldWidth / this.pixelRatio), oldHeight: Math.round(oldHeight / this.pixelRatio) }); + + // restore the camera on change. + this._setCameraState(); } return emitEvent; diff --git a/lib/network/modules/CanvasRenderer.js b/lib/network/modules/CanvasRenderer.js index 51653c2b0..a6c4ae1b2 100644 --- a/lib/network/modules/CanvasRenderer.js +++ b/lib/network/modules/CanvasRenderer.js @@ -57,6 +57,7 @@ class CanvasRenderer { }); this.body.emitter.on('destroy', () => { this.renderRequests = 0; + this.allowRedraw = false; this.renderingActive = false; if (this.requiresTimeout === true) { clearTimeout(this.renderTimer); @@ -146,13 +147,11 @@ class CanvasRenderer { this.canvas.setSize(); } - if (this.pixelRatio === undefined) { - this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio || - ctx.mozBackingStorePixelRatio || - ctx.msBackingStorePixelRatio || - ctx.oBackingStorePixelRatio || - ctx.backingStorePixelRatio || 1); - } + this.pixelRatio = (window.devicePixelRatio || 1) / (ctx.webkitBackingStorePixelRatio || + ctx.mozBackingStorePixelRatio || + ctx.msBackingStorePixelRatio || + ctx.oBackingStorePixelRatio || + ctx.backingStorePixelRatio || 1); ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0); @@ -161,6 +160,11 @@ class CanvasRenderer { let h = this.canvas.frame.canvas.clientHeight; ctx.clearRect(0, 0, w, h); + // if the div is hidden, we stop the redraw here for performance. + if (this.canvas.frame.clientWidth === 0) { + return; + } + // set scaling and translation ctx.save(); ctx.translate(this.body.view.translation.x, this.body.view.translation.y); @@ -180,17 +184,13 @@ class CanvasRenderer { this._drawNodes(ctx, hidden); } - if (this.controlNodesActive === true) { - this._drawControlNodes(ctx); - } - ctx.beginPath(); - //this.physics.nodesSolver._debug(ctx,"#F00F0F"); this.body.emitter.emit("afterDrawing", ctx); ctx.closePath(); + + // restore original scaling and translation ctx.restore(); - if (hidden === true) { ctx.clearRect(0, 0, w, h); } @@ -255,7 +255,6 @@ class CanvasRenderer { }); let viewableArea = {top:topLeft.y,left:topLeft.x,bottom:bottomRight.y,right:bottomRight.x}; - // draw unselected nodes; for (let i = 0; i < nodeIndices.length; i++) { node = nodes[nodeIndices[i]]; @@ -303,23 +302,6 @@ class CanvasRenderer { } } - /** - * Redraw all edges - * The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d'); - * @param {CanvasRenderingContext2D} ctx - * @private - */ - _drawControlNodes(ctx) { - let edges = this.body.edges; - let edgeIndices = this.body.edgeIndices; - let edge; - - for (let i = 0; i < edgeIndices.length; i++) { - edge = edges[edgeIndices[i]]; - edge._drawControlNodes(ctx); - } - } - /** * Determine if the browser requires a setTimeout or a requestAnimationFrame. This was required because * some implementations (safari and IE9) did not support requestAnimationFrame diff --git a/lib/network/modules/Clustering.js b/lib/network/modules/Clustering.js index 6b7eb0580..6b3c14b21 100644 --- a/lib/network/modules/Clustering.js +++ b/lib/network/modules/Clustering.js @@ -1,16 +1,18 @@ let util = require("../../util"); +import NetworkUtil from '../NetworkUtil'; import Cluster from './components/nodes/Cluster' class ClusterEngine { constructor(body) { this.body = body; this.clusteredNodes = {}; + this.clusteredEdges = {}; this.options = {}; this.defaultOptions = {}; util.extend(this.options, this.defaultOptions); - this.body.emitter.on('_resetData', () => {this.clusteredNodes = {};}) + this.body.emitter.on('_resetData', () => {this.clusteredNodes = {}; this.clusteredEdges = {};}) } setOptions(options) { @@ -42,8 +44,9 @@ class ClusterEngine { } for (let i = 0; i < nodesToCluster.length; i++) { - this.clusterByConnection(nodesToCluster[i],options,false); + this.clusterByConnection(nodesToCluster[i],options,true); } + this.body.emitter.emit('_dataChanged'); } @@ -66,14 +69,16 @@ class ClusterEngine { for (let i = 0; i < this.body.nodeIndices.length; i++) { let nodeId = this.body.nodeIndices[i]; let node = this.body.nodes[nodeId]; - let clonedOptions = this._cloneOptions(node); + let clonedOptions = NetworkUtil.cloneOptions(node); if (options.joinCondition(clonedOptions) === true) { childNodesObj[nodeId] = this.body.nodes[nodeId]; // collect the nodes that will be in the cluster for (let i = 0; i < node.edges.length; i++) { let edge = node.edges[i]; - childEdgesObj[edge.id] = edge; + if (this.clusteredEdges[edge.id] === undefined) { + childEdgesObj[edge.id] = edge; + } } } } @@ -83,53 +88,67 @@ class ClusterEngine { /** - * Cluster all nodes in the network that have only 1 edge - * @param options - * @param refreshData - */ - clusterOutliers(options, refreshData = true) { + * Cluster all nodes in the network that have only X edges + * @param edgeCount + * @param options + * @param refreshData + */ + clusterByEdgeCount(edgeCount, options, refreshData = true) { options = this._checkOptions(options); let clusters = []; - + let usedNodes = {}; + let edge, edges, node, nodeId, relevantEdgeCount; // collect the nodes that will be in the cluster for (let i = 0; i < this.body.nodeIndices.length; i++) { let childNodesObj = {}; let childEdgesObj = {}; - let nodeId = this.body.nodeIndices[i]; - let visibleEdges = 0; - let edge; - for (let j = 0; j < this.body.nodes[nodeId].edges.length; j++) { - if (this.body.nodes[nodeId].edges[j].options.hidden === false) { - visibleEdges++; - edge = this.body.nodes[nodeId].edges[j]; + nodeId = this.body.nodeIndices[i]; + + // if this node is already used in another cluster this session, we do not have to re-evaluate it. + if (usedNodes[nodeId] === undefined) { + relevantEdgeCount = 0; + node = this.body.nodes[nodeId]; + edges = []; + for (let j = 0; j < node.edges.length; j++) { + edge = node.edges[j]; + if (this.clusteredEdges[edge.id] === undefined) { + if (edge.toId !== edge.fromId) { + relevantEdgeCount++; + } + edges.push(edge); + } } - } - if (visibleEdges === 1) { - // this is an outlier - let childNodeId = this._getConnectedId(edge, nodeId); - if (childNodeId !== nodeId) { - if (options.joinCondition === undefined) { - if (this._checkIfUsed(clusters,nodeId,edge.id) === false && this._checkIfUsed(clusters,childNodeId,edge.id) === false) { + // this node qualifies, we collect its neighbours to start the clustering process. + if (relevantEdgeCount === edgeCount) { + let gatheringSuccessful = true; + for (let j = 0; j < edges.length; j++) { + edge = edges[j]; + let childNodeId = this._getConnectedId(edge, nodeId); + // add the nodes to the list by the join condition. + if (options.joinCondition === undefined) { childEdgesObj[edge.id] = edge; childNodesObj[nodeId] = this.body.nodes[nodeId]; childNodesObj[childNodeId] = this.body.nodes[childNodeId]; + usedNodes[nodeId] = true; } - } - else { - let clonedOptions = this._cloneOptions(this.body.nodes[nodeId]); - if (options.joinCondition(clonedOptions) === true && this._checkIfUsed(clusters,nodeId,edge.id) === false) { - childEdgesObj[edge.id] = edge; - childNodesObj[nodeId] = this.body.nodes[nodeId]; - } - clonedOptions = this._cloneOptions(this.body.nodes[childNodeId]); - if (options.joinCondition(clonedOptions) === true && this._checkIfUsed(clusters,nodeId,edge.id) === false) { - childEdgesObj[edge.id] = edge; - childNodesObj[childNodeId] = this.body.nodes[childNodeId]; + else { + let clonedOptions = NetworkUtil.cloneOptions(this.body.nodes[nodeId]); + if (options.joinCondition(clonedOptions) === true) { + childEdgesObj[edge.id] = edge; + childNodesObj[nodeId] = this.body.nodes[nodeId]; + usedNodes[nodeId] = true; + } + else { + // this node does not qualify after all. + gatheringSuccessful = false; + break; + } } } - if (Object.keys(childNodesObj).length > 0 && Object.keys(childEdgesObj).length > 0) { + // add to the cluster queue + if (Object.keys(childNodesObj).length > 0 && Object.keys(childEdgesObj).length > 0 && gatheringSuccessful === true) { clusters.push({nodes: childNodesObj, edges: childEdgesObj}) } } @@ -145,17 +164,26 @@ class ClusterEngine { } } - - _checkIfUsed(clusters, nodeId, edgeId) { - for (let i = 0; i < clusters.length; i++) { - let cluster = clusters[i]; - if (cluster.nodes[nodeId] !== undefined || cluster.edges[edgeId] !== undefined) { - return true; - } - } - return false; + /** + * Cluster all nodes in the network that have only 1 edge + * @param options + * @param refreshData + */ + clusterOutliers(options, refreshData = true) { + this.clusterByEdgeCount(1,options,refreshData); } + /** + * Cluster all nodes in the network that have only 2 edge + * @param options + * @param refreshData + */ + clusterBridges(options, refreshData = true) { + this.clusterByEdgeCount(2,options,refreshData); + } + + + /** * suck all connected nodes of a node into the node. * @param nodeId @@ -181,31 +209,37 @@ class ClusterEngine { let childNodesObj = {}; let childEdgesObj = {}; let parentNodeId = node.id; - let parentClonedOptions = this._cloneOptions(node); + let parentClonedOptions = NetworkUtil.cloneOptions(node); childNodesObj[parentNodeId] = node; // collect the nodes that will be in the cluster for (let i = 0; i < node.edges.length; i++) { let edge = node.edges[i]; - let childNodeId = this._getConnectedId(edge, parentNodeId); + if (this.clusteredEdges[edge.id] === undefined) { + let childNodeId = this._getConnectedId(edge, parentNodeId); - if (childNodeId !== parentNodeId) { - if (options.joinCondition === undefined) { - childEdgesObj[edge.id] = edge; - childNodesObj[childNodeId] = this.body.nodes[childNodeId]; - } - else { - // clone the options and insert some additional parameters that could be interesting. - let childClonedOptions = this._cloneOptions(this.body.nodes[childNodeId]); - if (options.joinCondition(parentClonedOptions, childClonedOptions) === true) { + // if the child node is not in a cluster + if (this.clusteredNodes[childNodeId] === undefined) { + if (childNodeId !== parentNodeId) { + if (options.joinCondition === undefined) { + childEdgesObj[edge.id] = edge; + childNodesObj[childNodeId] = this.body.nodes[childNodeId]; + } + else { + // clone the options and insert some additional parameters that could be interesting. + let childClonedOptions = NetworkUtil.cloneOptions(this.body.nodes[childNodeId]); + if (options.joinCondition(parentClonedOptions, childClonedOptions) === true) { + childEdgesObj[edge.id] = edge; + childNodesObj[childNodeId] = this.body.nodes[childNodeId]; + } + } + } + else { + // swallow the edge if it is self-referencing. childEdgesObj[edge.id] = edge; - childNodesObj[childNodeId] = this.body.nodes[childNodeId]; } } } - else { - childEdgesObj[edge.id] = edge; - } } this._cluster(childNodesObj, childEdgesObj, options, refreshData); @@ -213,40 +247,22 @@ class ClusterEngine { /** - * This returns a clone of the options or options of the edge or node to be used for construction of new edges or check functions for new nodes. - * @param objId - * @param type - * @returns {{}} - * @private - */ - _cloneOptions(item, type) { - let clonedOptions = {}; - if (type === undefined || type === 'node') { - util.deepExtend(clonedOptions, item.options, true); - clonedOptions.x = item.x; - clonedOptions.y = item.y; - clonedOptions.amountOfConnections = item.edges.length; - } - else { - util.deepExtend(clonedOptions, item.options, true); - } - return clonedOptions; - } - - - /** - * This function creates the edges that will be attached to the cluster. + * This function creates the edges that will be attached to the cluster + * It looks for edges that are connected to the nodes from the "outside' of the cluster. * * @param childNodesObj * @param childEdgesObj - * @param newEdges - * @param options + * @param clusterNodeProperties + * @param clusterEdgeProperties * @private */ - _createClusterEdges (childNodesObj, childEdgesObj, newEdges, clusterNodeProperties, clusterEdgeProperties) { + _createClusterEdges (childNodesObj, childEdgesObj, clusterNodeProperties, clusterEdgeProperties) { let edge, childNodeId, childNode, toId, fromId, otherNodeId; + // loop over all child nodes and their edges to find edges going out of the cluster + // these edges will be replaced by clusterEdges. let childKeys = Object.keys(childNodesObj); + let createEdges = []; for (let i = 0; i < childKeys.length; i++) { childNodeId = childKeys[i]; childNode = childNodesObj[childNodeId]; @@ -254,31 +270,62 @@ class ClusterEngine { // construct new edges from the cluster to others for (let j = 0; j < childNode.edges.length; j++) { edge = childNode.edges[j]; - childEdgesObj[edge.id] = edge; + // we only handle edges that are visible to the system, not the disabled ones from the clustering process. + if (this.clusteredEdges[edge.id] === undefined) { + // self-referencing edges will be added to the "hidden" list + if (edge.toId == edge.fromId) { + childEdgesObj[edge.id] = edge; + } + else { + // set up the from and to. + if (edge.toId == childNodeId) { // this is a double equals because ints and strings can be interchanged here. + toId = clusterNodeProperties.id; + fromId = edge.fromId; + otherNodeId = fromId; + } + else { + toId = edge.toId; + fromId = clusterNodeProperties.id; + otherNodeId = toId; + } + } - // childNodeId position will be replaced by the cluster. - if (edge.toId == childNodeId) { // this is a double equals because ints and strings can be interchanged here. - toId = clusterNodeProperties.id; - fromId = edge.fromId; - otherNodeId = fromId; - } - else { - toId = edge.toId; - fromId = clusterNodeProperties.id; - otherNodeId = toId; - } - - // if the node connected to the cluster is also in the cluster we do not need a new edge. - if (childNodesObj[otherNodeId] === undefined) { - let clonedOptions = this._cloneOptions(edge, 'edge'); - util.deepExtend(clonedOptions, clusterEdgeProperties); - clonedOptions.from = fromId; - clonedOptions.to = toId; - clonedOptions.id = 'clusterEdge:' + util.randomUUID(); - newEdges.push(this.body.functions.createEdge(clonedOptions)); + // Only edges from the cluster outwards are being replaced. + if (childNodesObj[otherNodeId] === undefined) { + createEdges.push({edge: edge, fromId: fromId, toId: toId}); + } } } } + + // here we actually create the replacement edges. We could not do this in the loop above as the creation process + // would add an edge to the edges array we are iterating over. + for (let j = 0; j < createEdges.length; j++) { + let edge = createEdges[j].edge; + // copy the options of the edge we will replace + let clonedOptions = NetworkUtil.cloneOptions(edge, 'edge'); + // make sure the properties of clusterEdges are superimposed on it + util.deepExtend(clonedOptions, clusterEdgeProperties); + + // set up the edge + clonedOptions.from = createEdges[j].fromId; + clonedOptions.to = createEdges[j].toId; + clonedOptions.id = 'clusterEdge:' + util.randomUUID(); + //clonedOptions.id = '(cf: ' + createEdges[j].fromId + " to: " + createEdges[j].toId + ")" + Math.random(); + + // create the edge and give a reference to the one it replaced. + let newEdge = this.body.functions.createEdge(clonedOptions); + newEdge.clusteringEdgeReplacingId = edge.id; + + // connect the edge. + this.body.edges[newEdge.id] = newEdge; + newEdge.connect(); + + // hide the replaced edge + this._backupEdgeOptions(edge); + edge.setOptions({physics:false, hidden:true}); + } + } /** @@ -304,8 +351,17 @@ class ClusterEngine { * @private */ _cluster(childNodesObj, childEdgesObj, options, refreshData = true) { - // kill condition: no children so cant cluster - if (Object.keys(childNodesObj).length === 0) {return;} + // kill condition: no children so can't cluster or only one node in the cluster, don't bother + if (Object.keys(childNodesObj).length < 2) {return;} + + // check if this cluster call is not trying to cluster anything that is in another cluster. + for (let nodeId in childNodesObj) { + if (childNodesObj.hasOwnProperty(nodeId)) { + if (this.clusteredNodes[nodeId] !== undefined) { + return; + } + } + } let clusterNodeProperties = util.deepExtend({},options.clusterNodeProperties); @@ -314,17 +370,21 @@ class ClusterEngine { // get the childNode options let childNodesOptions = []; for (let nodeId in childNodesObj) { - let clonedOptions = this._cloneOptions(childNodesObj[nodeId]); - childNodesOptions.push(clonedOptions); + if (childNodesObj.hasOwnProperty(nodeId)) { + let clonedOptions = NetworkUtil.cloneOptions(childNodesObj[nodeId]); + childNodesOptions.push(clonedOptions); + } } - // get clusterproperties based on childNodes + // get cluster properties based on childNodes let childEdgesOptions = []; for (let edgeId in childEdgesObj) { - // these cluster edges will be removed on creation of the cluster. - if (edgeId.substr(0,12) !== "clusterEdge:") { - let clonedOptions = this._cloneOptions(childEdgesObj[edgeId], 'edge'); - childEdgesOptions.push(clonedOptions); + if (childEdgesObj.hasOwnProperty(edgeId)) { + // these cluster edges will be removed on creation of the cluster. + if (edgeId.substr(0, 12) !== "clusterEdge:") { + let clonedOptions = NetworkUtil.cloneOptions(childEdgesObj[edgeId], 'edge'); + childEdgesOptions.push(clonedOptions); + } } } @@ -343,16 +403,14 @@ class ClusterEngine { } - // give the clusterNode a postion if it does not have one. + // give the clusterNode a position if it does not have one. let pos = undefined; if (clusterNodeProperties.x === undefined) { pos = this._getClusterPosition(childNodesObj); clusterNodeProperties.x = pos.x; } if (clusterNodeProperties.y === undefined) { - if (pos === undefined) { - pos = this._getClusterPosition(childNodesObj); - } + if (pos === undefined) {pos = this._getClusterPosition(childNodesObj);} clusterNodeProperties.y = pos.y; } @@ -370,48 +428,18 @@ class ClusterEngine { // finally put the cluster node into global this.body.nodes[clusterNodeProperties.id] = clusterNode; - // create the new edges that will connect to the cluster - let newEdges = []; - this._createClusterEdges(childNodesObj, childEdgesObj, newEdges, clusterNodeProperties, options.clusterEdgeProperties); + // create the new edges that will connect to the cluster, all self-referencing edges will be added to childEdgesObject here. + this._createClusterEdges(childNodesObj, childEdgesObj, clusterNodeProperties, options.clusterEdgeProperties); // disable the childEdges for (let edgeId in childEdgesObj) { if (childEdgesObj.hasOwnProperty(edgeId)) { if (this.body.edges[edgeId] !== undefined) { let edge = this.body.edges[edgeId]; - - // if the edge is a clusterEdge, we delete it. The opening of the clusters will restore these edges when required. - if (edgeId.substr(0,12) === "clusterEdge:") { - // we only delete the cluster edge if there is another edge to the node that is not a cluster. - let target = edge.from.isCluster === true ? edge.toId : edge.fromId; - let deleteEdge = false; - - // search the contained edges for an edge that has a link to the targetNode - for (let edgeId2 in childEdgesObj) { - if (childEdgesObj.hasOwnProperty(edgeId2)) { - if (this.body.edges[edgeId2] !== undefined && edgeId2 !== edgeId) { - let edge2 = this.body.edges[edgeId2]; - if (edge2.fromId == target || edge2.toId == target) { - deleteEdge = true; - break; - } - } - } - } - - // if we found the edge that will trigger the recreation of a new cluster edge on opening, we can delete this edge. - if (deleteEdge === true) { - edge.edgeType.cleanup(); - // this removes the edge from node.edges, which is why edgeIds is formed - edge.disconnect(); - delete childEdgesObj[edgeId]; - delete this.body.edges[edgeId]; - } - } - else { - edge.togglePhysics(false); - edge.options.hidden = true; - } + // cache the options before changing + this._backupEdgeOptions(edge); + // disable physics and hide the edge + edge.setOptions({physics:false, hidden:true}); } } } @@ -420,17 +448,10 @@ class ClusterEngine { for (let nodeId in childNodesObj) { if (childNodesObj.hasOwnProperty(nodeId)) { this.clusteredNodes[nodeId] = {clusterId:clusterNodeProperties.id, node: this.body.nodes[nodeId]}; - this.body.nodes[nodeId].togglePhysics(false); - this.body.nodes[nodeId].options.hidden = true; + this.body.nodes[nodeId].setOptions({hidden:true, physics:false}); } } - // push new edges to global - for (let i = 0; i < newEdges.length; i++) { - this.body.edges[newEdges[i].id] = newEdges[i]; - this.body.edges[newEdges[i].id].connect(); - } - // set ID to undefined so no duplicates arise clusterNodeProperties.id = undefined; @@ -440,6 +461,20 @@ class ClusterEngine { } } + _backupEdgeOptions(edge) { + if (this.clusteredEdges[edge.id] === undefined) { + this.clusteredEdges[edge.id] = {physics: edge.options.physics, hidden: edge.options.hidden}; + } + } + + _restoreEdge(edge) { + let originalOptions = this.clusteredEdges[edge.id]; + if (originalOptions !== undefined) { + edge.setOptions({physics: originalOptions.physics, hidden: originalOptions.hidden}); + delete this.clusteredEdges[edge.id]; + } + } + /** * Check if a node is a cluster. @@ -516,8 +551,8 @@ class ClusterEngine { if (containedNodes.hasOwnProperty(nodeId)) { let containedNode = this.body.nodes[nodeId]; if (newPositions[nodeId] !== undefined) { - containedNode.x = newPositions[nodeId].x || clusterNode.x; - containedNode.y = newPositions[nodeId].y || clusterNode.y; + containedNode.x = (newPositions[nodeId].x === undefined ? clusterNode.x : newPositions[nodeId].x); + containedNode.y = (newPositions[nodeId].y === undefined ? clusterNode.y : newPositions[nodeId].y); } } } @@ -529,8 +564,8 @@ class ClusterEngine { let containedNode = this.body.nodes[nodeId]; containedNode = containedNodes[nodeId]; // inherit position - containedNode.x = clusterNode.x; - containedNode.y = clusterNode.y; + if (containedNode.options.fixed.x === false) {containedNode.x = clusterNode.x;} + if (containedNode.options.fixed.y === false) {containedNode.y = clusterNode.y;} } } } @@ -544,76 +579,78 @@ class ClusterEngine { containedNode.vx = clusterNode.vx; containedNode.vy = clusterNode.vy; - containedNode.options.hidden = false; - containedNode.togglePhysics(true); + // we use these methods to avoid re-instantiating the shape, which happens with setOptions. + containedNode.setOptions({hidden:false, physics:true}); delete this.clusteredNodes[nodeId]; } } - // release edges - for (let edgeId in containedEdges) { - if (containedEdges.hasOwnProperty(edgeId)) { - let edge = containedEdges[edgeId]; - // if this edge was a temporary edge and it's connected nodes do not exist anymore, we remove it from the data - if (this.body.nodes[edge.fromId] === undefined || this.body.nodes[edge.toId] === undefined || edge.toId == clusterNodeId || edge.fromId == clusterNodeId) { - edge.edgeType.cleanup(); - // this removes the edge from node.edges, which is why edgeIds is formed - edge.disconnect(); - delete this.body.edges[edgeId]; - } - else { - // one of the nodes connected to this edge is in a cluster. We give the edge to that cluster so it will be released when that cluster is opened. - if (this.clusteredNodes[edge.fromId] !== undefined || this.clusteredNodes[edge.toId] !== undefined) { - let fromId, toId; - let clusteredNode = this.clusteredNodes[edge.fromId] || this.clusteredNodes[edge.toId]; - let clusterId = clusteredNode.clusterId; - let clusterNode = this.body.nodes[clusterId]; - clusterNode.containedEdges[edgeId] = edge; + // copy the clusterNode edges because we cannot iterate over an object that we add or remove from. + let edgesToBeDeleted = []; + for (let i = 0; i < clusterNode.edges.length; i++) { + edgesToBeDeleted.push(clusterNode.edges[i]); + } - if (this.clusteredNodes[edge.fromId] !== undefined) { - fromId = clusterId; - toId = edge.toId; - } - else { - fromId = edge.fromId; - toId = clusterId; - } + // actually handling the deleting. + for (let i = 0; i < edgesToBeDeleted.length; i++) { + let edge = edgesToBeDeleted[i]; - // if both from and to nodes are visible, we create a new temporary edge - if (this.body.nodes[fromId].options.hidden !== true && this.body.nodes[toId].options.hidden !== true) { - let clonedOptions = this._cloneOptions(edge, 'edge'); - let id = 'clusterEdge:' + util.randomUUID(); - util.deepExtend(clonedOptions, clusterNode.clusterEdgeProperties); - util.deepExtend(clonedOptions, {from: fromId, to: toId, hidden: false, physics: true, id: id}); - let newEdge = this.body.functions.createEdge(clonedOptions); + let otherNodeId = this._getConnectedId(edge, clusterNodeId); + // if the other node is in another cluster, we transfer ownership of this edge to the other cluster + if (this.clusteredNodes[otherNodeId] !== undefined) { + // transfer ownership: + let otherCluster = this.body.nodes[this.clusteredNodes[otherNodeId].clusterId]; + let transferEdge = this.body.edges[edge.clusteringEdgeReplacingId]; + if (transferEdge !== undefined) { + otherCluster.containedEdges[transferEdge.id] = transferEdge; - this.body.edges[id] = newEdge; - this.body.edges[id].connect(); - } + // delete local reference + delete containedEdges[transferEdge.id]; + + // create new cluster edge from the otherCluster: + // get to and from + let fromId = transferEdge.fromId; + let toId = transferEdge.toId; + if (transferEdge.toId == otherNodeId) { + toId = this.clusteredNodes[otherNodeId].clusterId; } else { - edge.options.hidden = false; - edge.togglePhysics(true); + fromId = this.clusteredNodes[otherNodeId].clusterId; } + + // clone the options and apply the cluster options to them + let clonedOptions = NetworkUtil.cloneOptions(transferEdge, 'edge'); + util.deepExtend(clonedOptions, otherCluster.clusterEdgeProperties); + + // apply the edge specific options to it. + let id = 'clusterEdge:' + util.randomUUID(); + util.deepExtend(clonedOptions, {from: fromId, to: toId, hidden: false, physics: true, id: id}); + + // create it + let newEdge = this.body.functions.createEdge(clonedOptions); + newEdge.clusteringEdgeReplacingId = transferEdge.id; + this.body.edges[id] = newEdge; + this.body.edges[id].connect(); } } - } - - // remove all temporary edges, make an array of ids so we don't remove from the list we're iterating over. - let removeIds = []; - for (let i = 0; i < clusterNode.edges.length; i++) { - let edgeId = clusterNode.edges[i].id; - removeIds.push(edgeId); - } - - // actually removing the edges - for (let i = 0; i < removeIds.length; i++) { - let edgeId = removeIds[i]; - this.body.edges[edgeId].edgeType.cleanup(); + else { + let replacedEdge = this.body.edges[edge.clusteringEdgeReplacingId]; + if (replacedEdge !== undefined) { + this._restoreEdge(replacedEdge); + } + } + edge.cleanup(); // this removes the edge from node.edges, which is why edgeIds is formed - this.body.edges[edgeId].disconnect(); - delete this.body.edges[edgeId]; + edge.disconnect(); + delete this.body.edges[edge.id]; + } + + // handle the releasing of the edges + for (let edgeId in containedEdges) { + if (containedEdges.hasOwnProperty(edgeId)) { + this._restoreEdge(containedEdges[edgeId]); + } } // remove clusterNode @@ -625,12 +662,12 @@ class ClusterEngine { } getNodesInCluster(clusterId) { - let nodesArray = [] + let nodesArray = []; if (this.isCluster(clusterId) === true) { let containedNodes = this.body.nodes[clusterId].containedNodes; for (let nodeId in containedNodes) { if (containedNodes.hasOwnProperty(nodeId)) { - nodesArray.push(nodeId) + nodesArray.push(this.body.nodes[nodeId].id) } } } @@ -642,7 +679,6 @@ class ClusterEngine { * Get the stack clusterId's that a certain node resides in. cluster A -> cluster B -> cluster C -> node * @param nodeId * @returns {Array} - * @private */ findNode(nodeId) { let stack = []; @@ -650,11 +686,13 @@ class ClusterEngine { let counter = 0; while (this.clusteredNodes[nodeId] !== undefined && counter < max) { - stack.push(this.clusteredNodes[nodeId].node); + stack.push(this.body.nodes[nodeId].id); nodeId = this.clusteredNodes[nodeId].clusterId; counter++; } - stack.push(this.body.nodes[nodeId]); + stack.push(this.body.nodes[nodeId].id); + stack.reverse(); + return stack; } @@ -702,8 +740,8 @@ class ClusterEngine { average = average / hubCounter; averageSquared = averageSquared / hubCounter; - let letiance = averageSquared - Math.pow(average,2); - let standardDeviation = Math.sqrt(letiance); + let variance = averageSquared - Math.pow(average,2); + let standardDeviation = Math.sqrt(variance); let hubThreshold = Math.floor(average + 2*standardDeviation); diff --git a/lib/network/modules/EdgesHandler.js b/lib/network/modules/EdgesHandler.js index cde73d5b3..4be26d95d 100644 --- a/lib/network/modules/EdgesHandler.js +++ b/lib/network/modules/EdgesHandler.js @@ -27,6 +27,7 @@ class EdgesHandler { middle: {enabled: false, scaleFactor:1}, from: {enabled: false, scaleFactor:1} }, + arrowStrikethrough: true, color: { color:'#848484', highlight:'#848484', @@ -74,6 +75,7 @@ class EdgesHandler { selfReferenceSize:20, shadow:{ enabled: false, + color: 'rgba(0,0,0,0.5)', size:10, x:5, y:5 @@ -81,6 +83,7 @@ class EdgesHandler { smooth: { enabled: true, type: "dynamic", + forceDirection:'none', roundness: 0.5 }, title:undefined, @@ -105,7 +108,7 @@ class EdgesHandler { let edge = this.body.edges[edgeId]; let edgeData = this.body.data.edges._data[edgeId]; - // only forcilby remove the smooth curve if the data has been set of the edge has the smooth curves defined. + // only forcibly remove the smooth curve if the data has been set of the edge has the smooth curves defined. // this is because a change in the global would not affect these curves. if (edgeData !== undefined) { let edgeOptions = edgeData.smooth; @@ -138,6 +141,10 @@ class EdgesHandler { this.body.emitter.on("refreshEdges", this.refresh.bind(this)); this.body.emitter.on("refresh", this.refresh.bind(this)); this.body.emitter.on("destroy", () => { + util.forEach(this.edgesListeners, (callback, event) => { + if (this.body.data.edges) + this.body.data.edges.off(event, callback); + }); delete this.body.functions.createEdge; delete this.edgesListeners.add; delete this.edgesListeners.update; @@ -152,7 +159,7 @@ class EdgesHandler { // use the parser from the Edge class to fill in all shorthand notations Edge.parseOptions(this.options, options); - // hanlde multiple input cases for color + // handle multiple input cases for color if (options.color !== undefined) { this.markAllEdgesAsDirty(); } @@ -275,7 +282,7 @@ class EdgesHandler { var id = ids[i]; var data = edgesData.get(id); var edge = edges[id]; - if (edge === null) { + if (edge !== undefined) { // update edge edge.disconnect(); dataChanged = edge.setOptions(data) || dataChanged; // if a support node is added, data can be changed. @@ -309,7 +316,7 @@ class EdgesHandler { var id = ids[i]; var edge = edges[id]; if (edge !== undefined) { - edge.edgeType.cleanup(); + edge.cleanup(); edge.disconnect(); delete edges[id]; } diff --git a/lib/network/modules/InteractionHandler.js b/lib/network/modules/InteractionHandler.js index 831dde5e5..f1b7fecd0 100644 --- a/lib/network/modules/InteractionHandler.js +++ b/lib/network/modules/InteractionHandler.js @@ -190,10 +190,12 @@ class InteractionHandler { let currentSelection = this.selectionHandler.getSelection(); let {nodesChanges, edgesChanges} = this._determineIfDifferent(previousSelection, currentSelection); + let nodeSelected = false; if (selectedNodesCount - previouslySelectedNodeCount > 0) { // node was selected this.selectionHandler._generateClickEvent('selectNode', event, pointer); selected = true; + nodeSelected = true; } else if (selectedNodesCount - previouslySelectedNodeCount < 0) { // node was deselected this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection); @@ -202,10 +204,12 @@ class InteractionHandler { else if (selectedNodesCount === previouslySelectedNodeCount && nodesChanges === true) { this.selectionHandler._generateClickEvent('deselectNode', event, pointer, previousSelection); this.selectionHandler._generateClickEvent('selectNode', event, pointer); + nodeSelected = true; selected = true; } - if (selectedEdgesCount - previouslySelectedEdgeCount > 0) { // edge was selected + // handle the selected edges + if (selectedEdgesCount - previouslySelectedEdgeCount > 0 && nodeSelected === false) { // edge was selected this.selectionHandler._generateClickEvent('selectEdge', event, pointer); selected = true; } @@ -219,7 +223,7 @@ class InteractionHandler { selected = true; } - + // fire the select event if anything has been selected or deselected if (selected === true) { // select or unselect this.selectionHandler._generateClickEvent('select', event, pointer); } @@ -483,38 +487,41 @@ class InteractionHandler { * @private */ onMouseWheel(event) { - // retrieve delta - let 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 !== 0) { - - // calculate the new scale - let scale = this.body.view.scale; - let zoom = delta / 10; - if (delta < 0) { - zoom = zoom / (1 - zoom); + if (this.options.zoomView === true) { + // retrieve delta + let 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; } - scale *= (1 + zoom); - // calculate the pointer location - let pointer = this.getPointer({x:event.clientX, y:event.clientY}); + // 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 !== 0) { - // apply the new scale - this.zoom(scale, pointer); + // calculate the new scale + let scale = this.body.view.scale; + let zoom = delta / 10; + if (delta < 0) { + zoom = zoom / (1 - zoom); + } + scale *= (1 + zoom); + + // calculate the pointer location + let pointer = this.getPointer({x: event.clientX, y: event.clientY}); + + // apply the new scale + this.zoom(scale, pointer); + } + + // Prevent default actions caused by mouse wheel. + event.preventDefault(); } - - // Prevent default actions caused by mouse wheel. - event.preventDefault(); } diff --git a/lib/network/modules/KamadaKawai.js b/lib/network/modules/KamadaKawai.js new file mode 100644 index 000000000..63d620ff8 --- /dev/null +++ b/lib/network/modules/KamadaKawai.js @@ -0,0 +1,215 @@ +// distance finding algorithm +import FloydWarshall from "./components/algorithms/FloydWarshall.js" + + +/** + * KamadaKawai positions the nodes initially based on + * + * "AN ALGORITHM FOR DRAWING GENERAL UNDIRECTED GRAPHS" + * -- Tomihisa KAMADA and Satoru KAWAI in 1989 + * + * Possible optimizations in the distance calculation can be implemented. + */ +class KamadaKawai { + constructor(body, edgeLength, edgeStrength) { + this.body = body; + this.springLength = edgeLength; + this.springConstant = edgeStrength; + this.distanceSolver = new FloydWarshall(); + } + + /** + * Not sure if needed but can be used to update the spring length and spring constant + * @param options + */ + setOptions(options) { + if (options) { + if (options.springLength) { + this.springLength = options.springLength; + } + if (options.springConstant) { + this.springConstant = options.springConstant; + } + } + } + + + /** + * Position the system + * @param nodesArray + * @param edgesArray + */ + solve(nodesArray, edgesArray, ignoreClusters = false) { + // get distance matrix + let D_matrix = this.distanceSolver.getDistances(this.body, nodesArray, edgesArray); // distance matrix + + // get the L Matrix + this._createL_matrix(D_matrix); + + // get the K Matrix + this._createK_matrix(D_matrix); + + // calculate positions + let threshold = 0.01; + let innerThreshold = 1; + let iterations = 0; + let maxIterations = Math.max(1000,Math.min(10*this.body.nodeIndices.length,6000)); + let maxInnerIterations = 5; + + let maxEnergy = 1e9; + let highE_nodeId = 0, dE_dx = 0, dE_dy = 0, delta_m = 0, subIterations = 0; + + while (maxEnergy > threshold && iterations < maxIterations) { + iterations += 1; + [highE_nodeId, maxEnergy, dE_dx, dE_dy] = this._getHighestEnergyNode(ignoreClusters); + delta_m = maxEnergy; + subIterations = 0; + while(delta_m > innerThreshold && subIterations < maxInnerIterations) { + subIterations += 1; + this._moveNode(highE_nodeId, dE_dx, dE_dy); + [delta_m,dE_dx,dE_dy] = this._getEnergy(highE_nodeId); + } + } + } + + /** + * get the node with the highest energy + * @returns {*[]} + * @private + */ + _getHighestEnergyNode(ignoreClusters) { + let nodesArray = this.body.nodeIndices; + let nodes = this.body.nodes; + let maxEnergy = 0; + let maxEnergyNodeId = nodesArray[0]; + let dE_dx_max = 0, dE_dy_max = 0; + + for (let nodeIdx = 0; nodeIdx < nodesArray.length; nodeIdx++) { + let m = nodesArray[nodeIdx]; + // by not evaluating nodes with predefined positions we should only move nodes that have no positions. + if ((nodes[m].predefinedPosition === false || nodes[m].isCluster === true && ignoreClusters === true) || nodes[m].options.fixed.x === true || nodes[m].options.fixed.y === true) { + let [delta_m,dE_dx,dE_dy] = this._getEnergy(m); + if (maxEnergy < delta_m) { + maxEnergy = delta_m; + maxEnergyNodeId = m; + dE_dx_max = dE_dx; + dE_dy_max = dE_dy; + } + } + } + + return [maxEnergyNodeId, maxEnergy, dE_dx_max, dE_dy_max]; + } + + /** + * calculate the energy of a single node + * @param m + * @returns {*[]} + * @private + */ + _getEnergy(m) { + let nodesArray = this.body.nodeIndices; + let nodes = this.body.nodes; + + let x_m = nodes[m].x; + let y_m = nodes[m].y; + let dE_dx = 0; + let dE_dy = 0; + for (let iIdx = 0; iIdx < nodesArray.length; iIdx++) { + let i = nodesArray[iIdx]; + if (i !== m) { + let x_i = nodes[i].x; + let y_i = nodes[i].y; + let denominator = 1.0 / Math.sqrt(Math.pow(x_m - x_i, 2) + Math.pow(y_m - y_i, 2)); + dE_dx += this.K_matrix[m][i] * ((x_m - x_i) - this.L_matrix[m][i] * (x_m - x_i) * denominator); + dE_dy += this.K_matrix[m][i] * ((y_m - y_i) - this.L_matrix[m][i] * (y_m - y_i) * denominator); + } + } + + let delta_m = Math.sqrt(Math.pow(dE_dx, 2) + Math.pow(dE_dy, 2)); + return [delta_m, dE_dx, dE_dy]; + } + + /** + * move the node based on it's energy + * the dx and dy are calculated from the linear system proposed by Kamada and Kawai + * @param m + * @param dE_dx + * @param dE_dy + * @private + */ + _moveNode(m, dE_dx, dE_dy) { + let nodesArray = this.body.nodeIndices; + let nodes = this.body.nodes; + let d2E_dx2 = 0; + let d2E_dxdy = 0; + let d2E_dy2 = 0; + + let x_m = nodes[m].x; + let y_m = nodes[m].y; + for (let iIdx = 0; iIdx < nodesArray.length; iIdx++) { + let i = nodesArray[iIdx]; + if (i !== m) { + let x_i = nodes[i].x; + let y_i = nodes[i].y; + let denominator = 1.0 / Math.pow(Math.pow(x_m - x_i, 2) + Math.pow(y_m - y_i, 2), 1.5); + d2E_dx2 += this.K_matrix[m][i] * (1 - this.L_matrix[m][i] * Math.pow(y_m - y_i, 2) * denominator); + d2E_dxdy += this.K_matrix[m][i] * (this.L_matrix[m][i] * (x_m - x_i) * (y_m - y_i) * denominator); + d2E_dy2 += this.K_matrix[m][i] * (1 - this.L_matrix[m][i] * Math.pow(x_m - x_i, 2) * denominator); + } + } + // make the variable names easier to make the solving of the linear system easier to read + let A = d2E_dx2, B = d2E_dxdy, C = dE_dx, D = d2E_dy2, E = dE_dy; + + // solve the linear system for dx and dy + let dy = (C / A + E / B) / (B / A - D / B); + let dx = -(B * dy + C) / A; + + // move the node + nodes[m].x += dx; + nodes[m].y += dy; + } + + + /** + * Create the L matrix: edge length times shortest path + * @param D_matrix + * @private + */ + _createL_matrix(D_matrix) { + let nodesArray = this.body.nodeIndices; + let edgeLength = this.springLength; + + this.L_matrix = []; + for (let i = 0; i < nodesArray.length; i++) { + this.L_matrix[nodesArray[i]] = {}; + for (let j = 0; j < nodesArray.length; j++) { + this.L_matrix[nodesArray[i]][nodesArray[j]] = edgeLength * D_matrix[nodesArray[i]][nodesArray[j]]; + } + } + } + + + /** + * Create the K matrix: spring constants times shortest path + * @param D_matrix + * @private + */ + _createK_matrix(D_matrix) { + let nodesArray = this.body.nodeIndices; + let edgeStrength = this.springConstant; + + this.K_matrix = []; + for (let i = 0; i < nodesArray.length; i++) { + this.K_matrix[nodesArray[i]] = {}; + for (let j = 0; j < nodesArray.length; j++) { + this.K_matrix[nodesArray[i]][nodesArray[j]] = edgeStrength * Math.pow(D_matrix[nodesArray[i]][nodesArray[j]], -2); + } + } + } + + + +} + +export default KamadaKawai; \ No newline at end of file diff --git a/lib/network/modules/LayoutEngine.js b/lib/network/modules/LayoutEngine.js index d663e1bd4..8e4fc6b35 100644 --- a/lib/network/modules/LayoutEngine.js +++ b/lib/network/modules/LayoutEngine.js @@ -1,6 +1,7 @@ -'use strict' +'use strict'; -var util = require('../../util'); +let util = require('../../util'); +import NetworkUtil from '../NetworkUtil'; class LayoutEngine { constructor(body) { @@ -8,22 +9,25 @@ class LayoutEngine { this.initialRandomSeed = Math.round(Math.random() * 1000000); this.randomSeed = this.initialRandomSeed; + this.setPhysics = false; this.options = {}; - this.optionsBackup = {}; + this.optionsBackup = {physics:{}}; this.defaultOptions = { randomSeed: undefined, + improvedLayout: true, hierarchical: { enabled:false, levelSeparation: 150, + nodeSpacing: 100, + treeSpacing: 200, + blockShifting: true, + edgeMinimization: true, direction: 'UD', // UD, DU, LR, RL sortMethod: 'hubsize' // hubsize, directed } }; util.extend(this.options, this.defaultOptions); - - this.hierarchicalLevels = {}; - this.bindEventListeners(); } @@ -31,6 +35,9 @@ class LayoutEngine { this.body.emitter.on('_dataChanged', () => { this.setupHierarchicalLayout(); }); + this.body.emitter.on('_dataLoaded', () => { + this.layoutNetwork(); + }); this.body.emitter.on('_resetHierarchicalLayout', () => { this.setupHierarchicalLayout(); }); @@ -39,19 +46,17 @@ class LayoutEngine { setOptions(options, allOptions) { if (options !== undefined) { let prevHierarchicalState = this.options.hierarchical.enabled; - + util.selectiveDeepExtend(["randomSeed", "improvedLayout"],this.options, options); util.mergeOptions(this.options, options, 'hierarchical'); - if (options.randomSeed !== undefined) { - this.initialRandomSeed = options.randomSeed; - } + if (options.randomSeed !== undefined) {this.initialRandomSeed = options.randomSeed;} if (this.options.hierarchical.enabled === true) { if (prevHierarchicalState === true) { // refresh the overridden options for nodes and edges. - this.body.emitter.emit('refresh'); + this.body.emitter.emit('refresh', true); } - // make sure the level seperation is the right way up + // make sure the level separation is the right way up if (this.options.hierarchical.direction === 'RL' || this.options.hierarchical.direction === 'DU') { if (this.options.hierarchical.levelSeparation > 0) { this.options.hierarchical.levelSeparation *= -1; @@ -65,7 +70,7 @@ class LayoutEngine { this.body.emitter.emit('_resetHierarchicalLayout'); // because the hierarchical system needs it's own physics and smooth curve settings, we adapt the other options if needed. - return this.adaptAllOptions(allOptions); + return this.adaptAllOptionsForHierarchicalLayout(allOptions); } else { if (prevHierarchicalState === true) { @@ -78,23 +83,25 @@ class LayoutEngine { return allOptions; } - adaptAllOptions(allOptions) { + adaptAllOptionsForHierarchicalLayout(allOptions) { if (this.options.hierarchical.enabled === true) { // set the physics if (allOptions.physics === undefined || allOptions.physics === true) { - allOptions.physics = {solver: 'hierarchicalRepulsion'}; - this.optionsBackup.physics = {solver:'barnesHut'}; + allOptions.physics = { + enabled:this.optionsBackup.physics.enabled === undefined ? true : this.optionsBackup.physics.enabled, + solver:'hierarchicalRepulsion' + }; + this.optionsBackup.physics.enabled = this.optionsBackup.physics.enabled === undefined ? true : this.optionsBackup.physics.enabled; + this.optionsBackup.physics.solver = this.optionsBackup.physics.solver || 'barnesHut'; } else if (typeof allOptions.physics === 'object') { - this.optionsBackup.physics = {solver:'barnesHut'}; - if (allOptions.physics.solver !== undefined) { - this.optionsBackup.physics = {solver:allOptions.physics.solver}; - } - allOptions.physics['solver'] = 'hierarchicalRepulsion'; + this.optionsBackup.physics.enabled = allOptions.physics.enabled === undefined ? true : allOptions.physics.enabled; + this.optionsBackup.physics.solver = allOptions.physics.solver || 'barnesHut'; + allOptions.physics.solver = 'hierarchicalRepulsion'; } else if (allOptions.physics !== false) { - this.optionsBackup.physics = {solver:'barnesHut'}; - allOptions.physics['solver'] = 'hierarchicalRepulsion'; + this.optionsBackup.physics.solver ='barnesHut'; + allOptions.physics = {solver:'hierarchicalRepulsion'}; } // get the type of static smooth curve in case it is required @@ -125,13 +132,15 @@ class LayoutEngine { this.optionsBackup.edges = { smooth: allOptions.edges.smooth.enabled === undefined ? true : allOptions.edges.smooth.enabled, - type:allOptions.edges.smooth.type === undefined ? 'dynamic' : allOptions.edges.smooth.type, - roundness: allOptions.edges.smooth.roundness === undefined ? 0.5 : allOptions.edges.smooth.roundness + type: allOptions.edges.smooth.type === undefined ? 'dynamic' : allOptions.edges.smooth.type, + roundness: allOptions.edges.smooth.roundness === undefined ? 0.5 : allOptions.edges.smooth.roundness, + forceDirection: allOptions.edges.smooth.forceDirection === undefined ? false : allOptions.edges.smooth.forceDirection }; allOptions.edges.smooth = { enabled: allOptions.edges.smooth.enabled === undefined ? true : allOptions.edges.smooth.enabled, type:type, - roundness: allOptions.edges.smooth.roundness === undefined ? 0.5 : allOptions.edges.smooth.roundness + roundness: allOptions.edges.smooth.roundness === undefined ? 0.5 : allOptions.edges.smooth.roundness, + forceDirection: allOptions.edges.smooth.forceDirection === undefined ? false : allOptions.edges.smooth.forceDirection } } } @@ -139,11 +148,12 @@ class LayoutEngine { // force all edges into static smooth curves. Only applies to edges that do not use the global options for smooth. this.body.emitter.emit('_forceDisableDynamicCurves', type); } + return allOptions; } seededRandom() { - var x = Math.sin(this.randomSeed++) * 10000; + let x = Math.sin(this.randomSeed++) * 10000; return x - Math.floor(x); } @@ -164,6 +174,107 @@ class LayoutEngine { } } + + /** + * Use Kamada Kawai to position nodes. This is quite a heavy algorithm so if there are a lot of nodes we + * cluster them first to reduce the amount. + */ + layoutNetwork() { + if (this.options.hierarchical.enabled !== true && this.options.improvedLayout === true) { + // first check if we should Kamada Kawai to layout. The threshold is if less than half of the visible + // nodes have predefined positions we use this. + let positionDefined = 0; + for (let i = 0; i < this.body.nodeIndices.length; i++) { + let node = this.body.nodes[this.body.nodeIndices[i]]; + if (node.predefinedPosition === true) { + positionDefined += 1; + } + } + + // if less than half of the nodes have a predefined position we continue + if (positionDefined < 0.5 * this.body.nodeIndices.length) { + let MAX_LEVELS = 10; + let level = 0; + let clusterThreshold = 100; + // if there are a lot of nodes, we cluster before we run the algorithm. + if (this.body.nodeIndices.length > clusterThreshold) { + let startLength = this.body.nodeIndices.length; + while (this.body.nodeIndices.length > clusterThreshold) { + //console.time("clustering") + level += 1; + let before = this.body.nodeIndices.length; + // if there are many nodes we do a hubsize cluster + if (level % 3 === 0) { + this.body.modules.clustering.clusterBridges(); + } + else { + this.body.modules.clustering.clusterOutliers(); + } + let after = this.body.nodeIndices.length; + if ((before == after && level % 3 !== 0) || level > MAX_LEVELS) { + this._declusterAll(); + this.body.emitter.emit("_layoutFailed"); + console.info("This network could not be positioned by this version of the improved layout algorithm. Please disable improvedLayout for better performance."); + return; + } + //console.timeEnd("clustering") + //console.log(level,after) + } + // increase the size of the edges + this.body.modules.kamadaKawai.setOptions({springLength: Math.max(150, 2 * startLength)}) + } + + // position the system for these nodes and edges + this.body.modules.kamadaKawai.solve(this.body.nodeIndices, this.body.edgeIndices, true); + + // shift to center point + this._shiftToCenter(); + + // perturb the nodes a little bit to force the physics to kick in + let offset = 70; + for (let i = 0; i < this.body.nodeIndices.length; i++) { + this.body.nodes[this.body.nodeIndices[i]].x += (0.5 - this.seededRandom())*offset; + this.body.nodes[this.body.nodeIndices[i]].y += (0.5 - this.seededRandom())*offset; + } + + // uncluster all clusters + this._declusterAll(); + + // reposition all bezier nodes. + this.body.emitter.emit("_repositionBezierNodes"); + } + } + } + + /** + * Move all the nodes towards to the center so gravitational pull wil not move the nodes away from view + * @private + */ + _shiftToCenter() { + let range = NetworkUtil.getRangeCore(this.body.nodes, this.body.nodeIndices); + let center = NetworkUtil.findCenter(range); + for (let i = 0; i < this.body.nodeIndices.length; i++) { + this.body.nodes[this.body.nodeIndices[i]].x -= center.x; + this.body.nodes[this.body.nodeIndices[i]].y -= center.y; + } + } + + _declusterAll() { + let clustersPresent = true; + while (clustersPresent === true) { + clustersPresent = false; + for (let i = 0; i < this.body.nodeIndices.length; i++) { + if (this.body.nodes[this.body.nodeIndices[i]].isCluster === true) { + clustersPresent = true; + this.body.modules.clustering.openCluster(this.body.nodeIndices[i], {}, false); + } + } + if (clustersPresent === true) { + this.body.emitter.emit('_dataChanged'); + } + } + } + getSeed() { return this.initialRandomSeed; } @@ -179,13 +290,26 @@ class LayoutEngine { // get the size of the largest hubs and check if the user has defined a level for a node. let node, nodeId; let definedLevel = false; + let definedPositions = true; let undefinedLevel = false; this.hierarchicalLevels = {}; - this.nodeSpacing = 100; + this.lastNodeOnLevel = {}; + this.hierarchicalParents = {}; + this.hierarchicalChildren = {}; + this.hierarchicalTrees = {}; + this.treeIndex = -1; + + this.distributionOrdering = {}; + this.distributionIndex = {}; + this.distributionOrderingPresence = {}; + for (nodeId in this.body.nodes) { if (this.body.nodes.hasOwnProperty(nodeId)) { node = this.body.nodes[nodeId]; + if (node.options.x === undefined && node.options.y === undefined) { + definedPositions = false; + } if (node.options.level !== undefined) { definedLevel = true; this.hierarchicalLevels[nodeId] = node.options.level; @@ -202,28 +326,506 @@ class LayoutEngine { return; } else { - // setup the system to use hierarchical method. - //this._changeConstants(); - - // define levels if undefined by the users. Based on hubsize + // define levels if undefined by the users. Based on hubsize. if (undefinedLevel === true) { if (this.options.hierarchical.sortMethod === 'hubsize') { this._determineLevelsByHubsize(); } - else if (this.options.hierarchical.sortMethod === 'directed' || 'direction') { + else if (this.options.hierarchical.sortMethod === 'directed') { this._determineLevelsDirected(); } + else if (this.options.hierarchical.sortMethod === 'custom') { + this._determineLevelsCustomCallback(); + } } + + // fallback for cases where there are nodes but no edges + for (let nodeId in this.body.nodes) { + if (this.body.nodes.hasOwnProperty(nodeId)) { + if (this.hierarchicalLevels[nodeId] === undefined) { + this.hierarchicalLevels[nodeId] = 0; + } + } + } // check the distribution of the nodes per level. let distribution = this._getDistribution(); + // get the parent children relations. + this._generateMap(); + // place the nodes on the canvas. this._placeNodesByHierarchy(distribution); + + // condense the whitespace. + this._condenseHierarchy(); + + // shift to center so gravity does not have to do much + this._shiftToCenter(); } } } + /** + * @private + */ + _condenseHierarchy() { + // Global var in this scope to define when the movement has stopped. + let stillShifting = false; + let branches = {}; + // first we have some methods to help shifting trees around. + // the main method to shift the trees + let shiftTrees = () => { + let treeSizes = getTreeSizes(); + for (let i = 0; i < treeSizes.length - 1; i++) { + let diff = treeSizes[i].max - treeSizes[i+1].min; + if (diff !== this.options.hierarchical.treeSpacing) { + shiftTree(i + 1, diff - this.options.hierarchical.treeSpacing); + } + } + }; + + // shift a single tree by an offset + let shiftTree = (index, offset) => { + for (let nodeId in this.hierarchicalTrees) { + if (this.hierarchicalTrees.hasOwnProperty(nodeId)) { + if (this.hierarchicalTrees[nodeId] === index) { + this._setPositionForHierarchy(this.body.nodes[nodeId], offset, undefined, true); + } + } + } + }; + + // get the width of a tree + let getTreeSize = (index) => { + let min = 1e9; + let max = -1e9; + for (let nodeId in this.hierarchicalTrees) { + if (this.hierarchicalTrees.hasOwnProperty(nodeId)) { + if (this.hierarchicalTrees[nodeId] === index) { + let pos = this._getPositionForHierarchy(this.body.nodes[nodeId]); + min = Math.min(pos, min); + max = Math.max(pos, max); + } + } + } + return {min:min, max:max}; + }; + + // get the width of all trees + let getTreeSizes = () => { + let treeWidths = []; + for (let i = 0; i < this.treeIndex; i++) { + treeWidths.push(getTreeSize(i)); + } + return treeWidths; + }; + + + // get a map of all nodes in this branch + let getBranchNodes = (source, map) => { + map[source.id] = true; + if (this.hierarchicalParents[source.id]) { + let children = this.hierarchicalParents[source.id].children; + if (children.length > 0) { + for (let i = 0; i < children.length; i++) { + getBranchNodes(this.body.nodes[children[i]], map); + } + } + } + }; + + // get a min max width as well as the maximum movement space it has on either sides + // we use min max terminology because width and height can interchange depending on the direction of the layout + let getBranchBoundary = (branchMap, maxLevel = 1e9) => { + let minSpace = 1e9; + let maxSpace = 1e9; + let min = 1e9; + let max = -1e9; + for (let branchNode in branchMap) { + if (branchMap.hasOwnProperty(branchNode)) { + let node = this.body.nodes[branchNode]; + let level = this.hierarchicalLevels[node.id]; + let position = this._getPositionForHierarchy(node); + + // get the space around the node. + let [minSpaceNode, maxSpaceNode] = this._getSpaceAroundNode(node,branchMap); + minSpace = Math.min(minSpaceNode, minSpace); + maxSpace = Math.min(maxSpaceNode, maxSpace); + + // the width is only relevant for the levels two nodes have in common. This is why we filter on this. + if (level <= maxLevel) { + min = Math.min(position, min); + max = Math.max(position, max); + } + } + } + + return [min, max, minSpace, maxSpace]; + }; + + // get the maximum level of a branch. + let getMaxLevel = (nodeId) => { + let level = this.hierarchicalLevels[nodeId]; + if (this.hierarchicalParents[nodeId]) { + let children = this.hierarchicalParents[nodeId].children; + if (children.length > 0) { + for (let i = 0; i < children.length; i++) { + level = Math.max(level,getMaxLevel(children[i])); + } + } + } + return level; + }; + + // check what the maximum level is these nodes have in common. + let getCollisionLevel = (node1, node2) => { + let maxLevel1 = getMaxLevel(node1.id); + let maxLevel2 = getMaxLevel(node2.id); + return Math.min(maxLevel1, maxLevel2); + }; + + // check if two nodes have the same parent(s) + let hasSameParent = (node1, node2) => { + let parents1 = this.hierarchicalChildren[node1.id]; + let parents2 = this.hierarchicalChildren[node2.id]; + if (parents1 === undefined || parents2 === undefined) { + return false; + } + parents1 = parents1.parents; + parents2 = parents2.parents; + for (let i = 0; i < parents1.length; i++) { + for (let j = 0; j < parents2.length; j++) { + if (parents1[i] == parents2[j]) { + return true; + } + } + } + return false; + }; + + // condense elements. These can be nodes or branches depending on the callback. + let shiftElementsCloser = (callback, levels, centerParents) => { + for (let i = 0; i < levels.length; i++) { + let level = levels[i]; + let levelNodes = this.distributionOrdering[level]; + if (levelNodes.length > 1) { + for (let j = 0; j < levelNodes.length - 1; j++) { + if (hasSameParent(levelNodes[j],levelNodes[j+1]) === true) { + if (this.hierarchicalTrees[levelNodes[j].id] === this.hierarchicalTrees[levelNodes[j+1].id]) { + callback(levelNodes[j],levelNodes[j+1], centerParents); + } + }} + } + } + }; + + // callback for shifting branches + let branchShiftCallback = (node1, node2, centerParent = false) => { + //window.CALLBACKS.push(() => { + let pos1 = this._getPositionForHierarchy(node1); + let pos2 = this._getPositionForHierarchy(node2); + let diffAbs = Math.abs(pos2 - pos1); + //console.log("NOW CHEcKING:", node1.id, node2.id, diffAbs); + if (diffAbs > this.options.hierarchical.nodeSpacing) { + let branchNodes1 = {}; branchNodes1[node1.id] = true; + let branchNodes2 = {}; branchNodes2[node2.id] = true; + + getBranchNodes(node1, branchNodes1); + getBranchNodes(node2, branchNodes2); + + // check the largest distance between the branches + let maxLevel = getCollisionLevel(node1, node2); + let [min1,max1, minSpace1, maxSpace1] = getBranchBoundary(branchNodes1, maxLevel); + let [min2,max2, minSpace2, maxSpace2] = getBranchBoundary(branchNodes2, maxLevel); + + //console.log(node1.id, getBranchBoundary(branchNodes1, maxLevel), node2.id, getBranchBoundary(branchNodes2, maxLevel), maxLevel); + let diffBranch = Math.abs(max1 - min2); + if (diffBranch > this.options.hierarchical.nodeSpacing) { + let offset = max1 - min2 + this.options.hierarchical.nodeSpacing; + if (offset < -minSpace2 + this.options.hierarchical.nodeSpacing) { + offset = -minSpace2 + this.options.hierarchical.nodeSpacing; + //console.log("RESETTING OFFSET", max1 - min2 + this.options.hierarchical.nodeSpacing, -minSpace2, offset); + } + if (offset < 0) { + //console.log("SHIFTING", node2.id, offset); + this._shiftBlock(node2.id, offset); + stillShifting = true; + + if (centerParent === true) + this._centerParent(node2); + } + } + + } + //this.body.emitter.emit("_redraw");}) + }; + + let minimizeEdgeLength = (iterations, node) => { + //window.CALLBACKS.push(() => { + // console.log("ts",node.id); + let nodeId = node.id; + let allEdges = node.edges; + let nodeLevel = this.hierarchicalLevels[node.id]; + + // gather constants + let C2 = this.options.hierarchical.levelSeparation * this.options.hierarchical.levelSeparation; + let referenceNodes = {}; + let aboveEdges = []; + for (let i = 0; i < allEdges.length; i++) { + let edge = allEdges[i]; + if (edge.toId != edge.fromId) { + let otherNode = edge.toId == nodeId ? edge.from : edge.to; + referenceNodes[allEdges[i].id] = otherNode; + if (this.hierarchicalLevels[otherNode.id] < nodeLevel) { + aboveEdges.push(edge); + } + } + } + + // differentiated sum of lengths based on only moving one node over one axis + let getFx = (point, edges) => { + let sum = 0; + for (let i = 0; i < edges.length; i++) { + if (referenceNodes[edges[i].id] !== undefined) { + let a = this._getPositionForHierarchy(referenceNodes[edges[i].id]) - point; + sum += a / Math.sqrt(a * a + C2); + } + } + return sum; + }; + + // doubly differentiated sum of lengths based on only moving one node over one axis + let getDFx = (point, edges) => { + let sum = 0; + for (let i = 0; i < edges.length; i++) { + if (referenceNodes[edges[i].id] !== undefined) { + let a = this._getPositionForHierarchy(referenceNodes[edges[i].id]) - point; + sum -= (C2 * Math.pow(a * a + C2, -1.5)); + } + } + return sum; + }; + + let getGuess = (iterations, edges) => { + let guess = this._getPositionForHierarchy(node); + // Newton's method for optimization + let guessMap = {}; + for (let i = 0; i < iterations; i++) { + let fx = getFx(guess, edges); + let dfx = getDFx(guess, edges); + + // we limit the movement to avoid instability. + let limit = 40; + let ratio = Math.max(-limit, Math.min(limit, Math.round(fx/dfx))); + guess = guess - ratio; + // reduce duplicates + if (guessMap[guess] !== undefined) { + break; + } + guessMap[guess] = i; + } + return guess; + }; + + let moveBranch = (guess) => { + // position node if there is space + let nodePosition = this._getPositionForHierarchy(node); + + // check movable area of the branch + if (branches[node.id] === undefined) { + let branchNodes = {}; + branchNodes[node.id] = true; + getBranchNodes(node, branchNodes); + branches[node.id] = branchNodes; + } + let [minBranch, maxBranch, minSpaceBranch, maxSpaceBranch] = getBranchBoundary(branches[node.id]); + + let diff = guess - nodePosition; + + // check if we are allowed to move the node: + let branchOffset = 0; + if (diff > 0) { + branchOffset = Math.min(diff, maxSpaceBranch - this.options.hierarchical.nodeSpacing); + } + else if (diff < 0) { + branchOffset = -Math.min(-diff, minSpaceBranch - this.options.hierarchical.nodeSpacing); + } + + if (branchOffset != 0) { + //console.log("moving branch:",branchOffset, maxSpaceBranch, minSpaceBranch) + this._shiftBlock(node.id, branchOffset); + //this.body.emitter.emit("_redraw"); + stillShifting = true; + } + }; + + let moveNode = (guess) => { + let nodePosition = this._getPositionForHierarchy(node); + + // position node if there is space + let [minSpace, maxSpace] = this._getSpaceAroundNode(node); + let diff = guess - nodePosition; + // check if we are allowed to move the node: + let newPosition = nodePosition; + if (diff > 0) { + newPosition = Math.min(nodePosition + (maxSpace - this.options.hierarchical.nodeSpacing), guess); + } + else if (diff < 0) { + newPosition = Math.max(nodePosition - (minSpace - this.options.hierarchical.nodeSpacing), guess); + } + + if (newPosition !== nodePosition) { + //console.log("moving Node:",diff, minSpace, maxSpace) + this._setPositionForHierarchy(node, newPosition, undefined, true); + //this.body.emitter.emit("_redraw"); + stillShifting = true; + } + }; + + let guess = getGuess(iterations, aboveEdges); + moveBranch(guess); + guess = getGuess(iterations, allEdges); + moveNode(guess); + //}) + }; + + // method to remove whitespace between branches. Because we do bottom up, we can center the parents. + let minimizeEdgeLengthBottomUp = (iterations) => { + let levels = Object.keys(this.distributionOrdering); + levels = levels.reverse(); + for (let i = 0; i < iterations; i++) { + stillShifting = false; + for (let j = 0; j < levels.length; j++) { + let level = levels[j]; + let levelNodes = this.distributionOrdering[level]; + for (let k = 0; k < levelNodes.length; k++) { + minimizeEdgeLength(1000, levelNodes[k]); + } + } + if (stillShifting !== true) { + //console.log("FINISHED minimizeEdgeLengthBottomUp IN " + i); + break; + } + } + }; + + //// method to remove whitespace between branches. Because we do bottom up, we can center the parents. + let shiftBranchesCloserBottomUp = (iterations) => { + let levels = Object.keys(this.distributionOrdering); + levels = levels.reverse(); + for (let i = 0; i < iterations; i++) { + stillShifting = false; + shiftElementsCloser(branchShiftCallback, levels, true); + if (stillShifting !== true) { + //console.log("FINISHED shiftBranchesCloserBottomUp IN " + (i+1)); + break; + } + } + }; + + // center all parents + let centerAllParents = () => { + for (let nodeId in this.body.nodes) { + if (this.body.nodes.hasOwnProperty(nodeId)) + this._centerParent(this.body.nodes[nodeId]); + } + }; + + // the actual work is done here. + if (this.options.hierarchical.blockShifting === true) { + shiftBranchesCloserBottomUp(5); + centerAllParents(); + } + + // minimize edge length + if (this.options.hierarchical.edgeMinimization === true) { + minimizeEdgeLengthBottomUp(20); + } + + shiftTrees(); + } + + /** + * This gives the space around the node. IF a map is supplied, it will only check against nodes NOT in the map. + * This is used to only get the distances to nodes outside of a branch. + * @param node + * @param map + * @returns {*[]} + * @private + */ + _getSpaceAroundNode(node, map) { + let useMap = true; + if (map === undefined) { + useMap = false; + } + let level = this.hierarchicalLevels[node.id]; + if (level !== undefined) { + let index = this.distributionIndex[node.id]; + let position = this._getPositionForHierarchy(node); + let minSpace = 1e9; + let maxSpace = 1e9; + if (index !== 0) { + let prevNode = this.distributionOrdering[level][index - 1]; + if ((useMap === true && map[prevNode.id] === undefined) || useMap === false) { + let prevPos = this._getPositionForHierarchy(prevNode); + minSpace = position - prevPos; + } + } + + if (index != this.distributionOrdering[level].length - 1) { + let nextNode = this.distributionOrdering[level][index + 1]; + if ((useMap === true && map[nextNode.id] === undefined) || useMap === false) { + let nextPos = this._getPositionForHierarchy(nextNode); + maxSpace = Math.min(maxSpace, nextPos - position); + } + } + + return [minSpace, maxSpace]; + } + else { + return [0, 0]; + } + } + + /** + * We use this method to center a parent node and check if it does not cross other nodes when it does. + * @param node + * @private + */ + _centerParent(node) { + if (this.hierarchicalChildren[node.id]) { + let parents = this.hierarchicalChildren[node.id].parents; + for (var i = 0; i < parents.length; i++) { + let parentId = parents[i]; + let parentNode = this.body.nodes[parentId]; + if (this.hierarchicalParents[parentId]) { + // get the range of the children + let minPos = 1e9; + let maxPos = -1e9; + let children = this.hierarchicalParents[parentId].children; + if (children.length > 0) { + for (let i = 0; i < children.length; i++) { + let childNode = this.body.nodes[children[i]]; + minPos = Math.min(minPos, this._getPositionForHierarchy(childNode)); + maxPos = Math.max(maxPos, this._getPositionForHierarchy(childNode)); + } + } + + let position = this._getPositionForHierarchy(parentNode); + let [minSpace, maxSpace] = this._getSpaceAroundNode(parentNode); + let newPosition = 0.5 * (minPos + maxPos); + let diff = position - newPosition; + if ((diff < 0 && Math.abs(diff) < maxSpace - this.options.hierarchical.nodeSpacing) || (diff > 0 && Math.abs(diff) < minSpace - this.options.hierarchical.nodeSpacing)) { + this._setPositionForHierarchy(parentNode, newPosition, undefined, true); + } + } + } + } + } + + + /** * This function places the nodes on the canvas based on the hierarchial distribution. * @@ -231,33 +833,39 @@ class LayoutEngine { * @private */ _placeNodesByHierarchy(distribution) { - let nodeId, node; this.positionedNodes = {}; // start placing all the level 0 nodes first. Then recursively position their branches. for (let level in distribution) { if (distribution.hasOwnProperty(level)) { - for (nodeId in distribution[level].nodes) { - if (distribution[level].nodes.hasOwnProperty(nodeId)) { + // sort nodes in level by position: + let nodeArray = Object.keys(distribution[level]); + nodeArray = this._indexArrayToNodes(nodeArray); + this._sortNodeArray(nodeArray); - node = distribution[level].nodes[nodeId]; - - if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { - if (node.x === undefined) {node.x = distribution[level].distance;} - distribution[level].distance = node.x + this.nodeSpacing; - } - else { - if (node.y === undefined) {node.y = distribution[level].distance;} - distribution[level].distance = node.y + this.nodeSpacing; - } - - this.positionedNodes[nodeId] = true; - this._placeBranchNodes(node.edges,node.id,distribution,level); + for (let i = 0; i < nodeArray.length; i++) { + let node = nodeArray[i]; + if (this.positionedNodes[node.id] === undefined) { + this._setPositionForHierarchy(node, this.options.hierarchical.nodeSpacing * i, level); + this.positionedNodes[node.id] = true; + this._placeBranchNodes(node.id, level); } } } } } + /** + * Receives an array with node indices and returns an array with the actual node references. Used for sorting based on + * node properties. + * @param idArray + */ + _indexArrayToNodes(idArray) { + let array = []; + for (let i = 0; i < idArray.length; i++) { + array.push(this.body.nodes[idArray[i]]) + } + return array; + } /** * This function get the distribution of levels based on hubsize @@ -284,10 +892,9 @@ class LayoutEngine { node.options.fixed.x = true; } if (distribution[level] === undefined) { - distribution[level] = {amount: 0, nodes: {}, distance: 0}; + distribution[level] = {}; } - distribution[level].amount += 1; - distribution[level].nodes[nodeId] = node; + distribution[level][nodeId] = node; } } return distribution; @@ -321,54 +928,66 @@ class LayoutEngine { * @private */ _determineLevelsByHubsize() { - let nodeId, node; let hubSize = 1; + let levelDownstream = (nodeA, nodeB) => { + if (this.hierarchicalLevels[nodeB.id] === undefined) { + // set initial level + if (this.hierarchicalLevels[nodeA.id] === undefined) { + this.hierarchicalLevels[nodeA.id] = 0; + } + // set level + this.hierarchicalLevels[nodeB.id] = this.hierarchicalLevels[nodeA.id] + 1; + } + }; + while (hubSize > 0) { // determine hubs hubSize = this._getHubSize(); if (hubSize === 0) break; - for (nodeId in this.body.nodes) { + for (let nodeId in this.body.nodes) { if (this.body.nodes.hasOwnProperty(nodeId)) { - node = this.body.nodes[nodeId]; + let node = this.body.nodes[nodeId]; if (node.edges.length === hubSize) { - this._setLevelByHubsize(0, node); + this._crawlNetwork(levelDownstream,nodeId); } } } } } - /** - * this function is called recursively to enumerate the barnches of the largest hubs and give each node a level. - * - * @param level - * @param edges - * @param parentId + * TODO: release feature * @private */ - _setLevelByHubsize(level, node) { - if (this.hierarchicalLevels[node.id] !== undefined) - return; + _determineLevelsCustomCallback() { + let minLevel = 100000; - let childNode; - this.hierarchicalLevels[node.id] = level; - for (let i = 0; i < node.edges.length; i++) { - if (node.edges[i].toId === node.id) { - childNode = node.edges[i].from; - } - else { - childNode = node.edges[i].to; - } - this._setLevelByHubsize(level + 1, childNode); - } + // TODO: this should come from options. + let customCallback = function(nodeA, nodeB, edge) { + + }; + + let levelByDirection = (nodeA, nodeB, edge) => { + let levelA = this.hierarchicalLevels[nodeA.id]; + // set initial level + if (levelA === undefined) {this.hierarchicalLevels[nodeA.id] = minLevel;} + + let diff = customCallback( + NetworkUtil.cloneOptions(nodeA,'node'), + NetworkUtil.cloneOptions(nodeB,'node'), + NetworkUtil.cloneOptions(edge,'edge') + ); + + this.hierarchicalLevels[nodeB.id] = this.hierarchicalLevels[nodeA.id] + diff; + }; + + this._crawlNetwork(levelByDirection); + this._setMinLevelToZero(); } - - /** * this function allocates nodes in levels based on the direction of the edges * @@ -376,111 +995,340 @@ class LayoutEngine { * @private */ _determineLevelsDirected() { - let nodeId, node; let minLevel = 10000; - - // set first node to source - for (nodeId in this.body.nodes) { - if (this.body.nodes.hasOwnProperty(nodeId)) { - node = this.body.nodes[nodeId]; - this._setLevelDirected(minLevel,node); + let levelByDirection = (nodeA, nodeB, edge) => { + let levelA = this.hierarchicalLevels[nodeA.id]; + // set initial level + if (levelA === undefined) {this.hierarchicalLevels[nodeA.id] = minLevel;} + if (edge.toId == nodeB.id) { + this.hierarchicalLevels[nodeB.id] = this.hierarchicalLevels[nodeA.id] + 1; } - } + else { + this.hierarchicalLevels[nodeB.id] = this.hierarchicalLevels[nodeA.id] - 1; + } + }; + this._crawlNetwork(levelByDirection); + this._setMinLevelToZero(); + } + + /** + * Small util method to set the minimum levels of the nodes to zero. + * @private + */ + _setMinLevelToZero() { + let minLevel = 1e9; // get the minimum level - for (nodeId in this.body.nodes) { + for (let nodeId in this.body.nodes) { if (this.body.nodes.hasOwnProperty(nodeId)) { - minLevel = this.hierarchicalLevels[nodeId] < minLevel ? this.hierarchicalLevels[nodeId] : minLevel; + if (this.hierarchicalLevels[nodeId] !== undefined) { + minLevel = Math.min(this.hierarchicalLevels[nodeId], minLevel); + } } } // subtract the minimum from the set so we have a range starting from 0 - for (nodeId in this.body.nodes) { + for (let nodeId in this.body.nodes) { if (this.body.nodes.hasOwnProperty(nodeId)) { - this.hierarchicalLevels[nodeId] -= minLevel; + if (this.hierarchicalLevels[nodeId] !== undefined) { + this.hierarchicalLevels[nodeId] -= minLevel; + } } } } /** - * this function is called recursively to enumerate the branched of the first node and give each node a level based on edge direction - * - * @param level - * @param edges - * @param parentId + * Update the bookkeeping of parent and child. * @private */ - _setLevelDirected(level, node) { - if (this.hierarchicalLevels[node.id] !== undefined) - return; - - let childNode; - this.hierarchicalLevels[node.id] = level; - - for (let i = 0; i < node.edges.length; i++) { - if (node.edges[i].toId === node.id) { - childNode = node.edges[i].from; - this._setLevelDirected(level - 1, childNode); + _generateMap() { + let fillInRelations = (parentNode, childNode) => { + if (this.hierarchicalLevels[childNode.id] > this.hierarchicalLevels[parentNode.id]) { + let parentNodeId = parentNode.id; + let childNodeId = childNode.id; + if (this.hierarchicalParents[parentNodeId] === undefined) { + this.hierarchicalParents[parentNodeId] = {children: [], amount: 0}; + } + this.hierarchicalParents[parentNodeId].children.push(childNodeId); + if (this.hierarchicalChildren[childNodeId] === undefined) { + this.hierarchicalChildren[childNodeId] = {parents: [], amount: 0}; + } + this.hierarchicalChildren[childNodeId].parents.push(parentNodeId); } - else { - childNode = node.edges[i].to; - this._setLevelDirected(level + 1, childNode); - } - } + }; + + this._crawlNetwork(fillInRelations); } + /** + * Crawl over the entire network and use a callback on each node couple that is connected to each other. + * @param callback | will receive nodeA nodeB and the connecting edge. A and B are unique. + * @param startingNodeId + * @private + */ + _crawlNetwork(callback = function() {}, startingNodeId) { + let progress = {}; + let crawler = (node) => { + if (progress[node.id] === undefined) { + progress[node.id] = true; + let childNode; + for (let i = 0; i < node.edges.length; i++) { + if (node.edges[i].connected === true) { + if (node.edges[i].toId === node.id) { + childNode = node.edges[i].from; + } + else { + childNode = node.edges[i].to; + } + + if (node.id !== childNode.id) { + callback(node, childNode, node.edges[i]); + crawler(childNode); + } + } + } + } + }; + + + // we can crawl from a specific node or over all nodes. + if (startingNodeId === undefined) { + for (let i = 0; i < this.body.nodeIndices.length; i++) { + let node = this.body.nodes[this.body.nodeIndices[i]]; + crawler(node); + } + } + else { + let node = this.body.nodes[startingNodeId]; + if (node === undefined) { + console.error("Node not found:", startingNodeId); + return; + } + crawler(node); + } + } + /** * This is a recursively called function to enumerate the branches from the largest hubs and place the nodes * on a X position that ensures there will be no overlap. * - * @param edges * @param parentId - * @param distribution * @param parentLevel * @private */ - _placeBranchNodes(edges, parentId, distribution, parentLevel) { - for (let i = 0; i < edges.length; i++) { - let childNode = undefined; - let parentNode = undefined; - if (edges[i].toId === parentId) { - childNode = edges[i].from; - parentNode = edges[i].to; - } - else { - childNode = edges[i].to; - parentNode = edges[i].from; - } + _placeBranchNodes(parentId, parentLevel) { + // if this is not a parent, cancel the placing. This can happen with multiple parents to one child. + if (this.hierarchicalParents[parentId] === undefined) { + return; + } + + // get a list of childNodes + let childNodes = []; + for (let i = 0; i < this.hierarchicalParents[parentId].children.length; i++) { + childNodes.push(this.body.nodes[this.hierarchicalParents[parentId].children[i]]); + } + + // use the positions to order the nodes. + this._sortNodeArray(childNodes); + + // position the childNodes + for (let i = 0; i < childNodes.length; i++) { + let childNode = childNodes[i]; let childNodeLevel = this.hierarchicalLevels[childNode.id]; + // check if the child node is below the parent node and if it has already been positioned. + if (childNodeLevel > parentLevel && this.positionedNodes[childNode.id] === undefined) { + // get the amount of space required for this node. If parent the width is based on the amount of children. + let pos; - if (this.positionedNodes[childNode.id] === undefined) { - // if a node is conneceted to another node on the same level (or higher (means lower level))!, this is not handled here. - if (childNodeLevel > parentLevel) { - if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { - if (childNode.x === undefined) { - childNode.x = Math.max(distribution[childNodeLevel].distance, parentNode.x); - } - distribution[childNodeLevel].distance = childNode.x + this.nodeSpacing; - this.positionedNodes[childNode.id] = true; - } - else { - if (childNode.y === undefined) { - childNode.y = Math.max(distribution[childNodeLevel].distance, parentNode.y) - } - distribution[childNodeLevel].distance = childNode.y + this.nodeSpacing; - } - this.positionedNodes[childNode.id] = true; + // we get the X or Y values we need and store them in pos and previousPos. The get and set make sure we get X or Y + if (i === 0) {pos = this._getPositionForHierarchy(this.body.nodes[parentId]);} + else {pos = this._getPositionForHierarchy(childNodes[i-1]) + this.options.hierarchical.nodeSpacing;} + this._setPositionForHierarchy(childNode, pos, childNodeLevel); - if (childNode.edges.length > 1) { - this._placeBranchNodes(childNode.edges, childNode.id, distribution, childNodeLevel); + // if overlap has been detected, we shift the branch + if (this.lastNodeOnLevel[childNodeLevel] !== undefined) { + let previousPos = this._getPositionForHierarchy(this.body.nodes[this.lastNodeOnLevel[childNodeLevel]]); + if (pos - previousPos < this.options.hierarchical.nodeSpacing) { + let diff = (previousPos + this.options.hierarchical.nodeSpacing) - pos; + let sharedParent = this._findCommonParent(this.lastNodeOnLevel[childNodeLevel], childNode.id); + this._shiftBlock(sharedParent.withChild, diff); } } + + // store change in position. + this.lastNodeOnLevel[childNodeLevel] = childNode.id; + + this.positionedNodes[childNode.id] = true; + + this._placeBranchNodes(childNode.id, childNodeLevel); + } + else { + return; + } + } + + // center the parent nodes. + let minPos = 1e9; + let maxPos = -1e9; + for (let i = 0; i < childNodes.length; i++) { + let childNodeId = childNodes[i].id; + minPos = Math.min(minPos, this._getPositionForHierarchy(this.body.nodes[childNodeId])); + maxPos = Math.max(maxPos, this._getPositionForHierarchy(this.body.nodes[childNodeId])); + } + this._setPositionForHierarchy(this.body.nodes[parentId], 0.5 * (minPos + maxPos), parentLevel); + } + + + /** + * Shift a branch a certain distance + * @param parentId + * @param diff + * @private + */ + _shiftBlock(parentId, diff) { + if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { + this.body.nodes[parentId].x += diff; + } + else { + this.body.nodes[parentId].y += diff; + } + if (this.hierarchicalParents[parentId] !== undefined) { + for (let i = 0; i < this.hierarchicalParents[parentId].children.length; i++) { + this._shiftBlock(this.hierarchicalParents[parentId].children[i], diff); } } } + + + /** + * Find a common parent between branches. + * @param childA + * @param childB + * @returns {{foundParent, withChild}} + * @private + */ + _findCommonParent(childA,childB) { + let parents = {}; + let iterateParents = (parents,child) => { + if (this.hierarchicalChildren[child] !== undefined) { + for (let i = 0; i < this.hierarchicalChildren[child].parents.length; i++) { + let parent = this.hierarchicalChildren[child].parents[i]; + parents[parent] = true; + iterateParents(parents, parent) + } + } + }; + let findParent = (parents, child) => { + if (this.hierarchicalChildren[child] !== undefined) { + for (let i = 0; i < this.hierarchicalChildren[child].parents.length; i++) { + let parent = this.hierarchicalChildren[child].parents[i]; + if (parents[parent] !== undefined) { + return {foundParent:parent, withChild:child}; + } + let branch = findParent(parents, parent); + if (branch.foundParent !== null) { + return branch; + } + } + } + return {foundParent:null, withChild:child}; + }; + + iterateParents(parents, childA); + return findParent(parents, childB); + } + + /** + * Abstract the getting of the position so we won't have to repeat the check for direction all the time + * @param node + * @param position + * @param level + * @private + */ + _setPositionForHierarchy(node, position, level, doNotUpdate = false) { + if (doNotUpdate !== true) { + if (this.distributionOrdering[level] === undefined) { + this.distributionOrdering[level] = []; + this.distributionOrderingPresence[level] = {}; + } + + if (this.distributionOrderingPresence[level][node.id] === undefined) { + this.distributionOrdering[level].push(node); + this.distributionIndex[node.id] = this.distributionOrdering[level].length - 1; + } + this.distributionOrderingPresence[level][node.id] = true; + + if (this.hierarchicalTrees[node.id] === undefined) { + if (this.hierarchicalChildren[node.id] !== undefined) { + let tree = 1; + // get the lowest tree denominator. + for (let i = 0; i < this.hierarchicalChildren[node.id].parents.length; i++) { + let parentId = this.hierarchicalChildren[node.id].parents[i]; + if (this.hierarchicalTrees[parentId] !== undefined) { + //tree = Math.min(tree,this.hierarchicalTrees[parentId]); + tree = this.hierarchicalTrees[parentId]; + } + } + //for (let i = 0; i < this.hierarchicalChildren.parents.length; i++) { + // let parentId = this.hierarchicalChildren.parents[i]; + // this.hierarchicalTrees[parentId] = tree; + //} + this.hierarchicalTrees[node.id] = tree; + } + else { + this.hierarchicalTrees[node.id] = ++this.treeIndex; + } + } + } + + if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { + node.x = position; + } + else { + node.y = position; + } + } + + /** + * Abstract the getting of the position of a node so we do not have to repeat the direction check all the time. + * @param node + * @returns {number|*} + * @private + */ + _getPositionForHierarchy(node) { + if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { + return node.x; + } + else { + return node.y; + } + } + + /** + * Use the x or y value to sort the array, allowing users to specify order. + * @param nodeArray + * @private + */ + _sortNodeArray(nodeArray) { + if (nodeArray.length > 1) { + if (this.options.hierarchical.direction === 'UD' || this.options.hierarchical.direction === 'DU') { + nodeArray.sort(function (a, b) { + return a.x - b.x; + }) + } + else { + nodeArray.sort(function (a, b) { + return a.y - b.y; + }) + } + } + } + + + } export default LayoutEngine; \ No newline at end of file diff --git a/lib/network/modules/ManipulationSystem.js b/lib/network/modules/ManipulationSystem.js index f6a6a247e..158f6a9a1 100644 --- a/lib/network/modules/ManipulationSystem.js +++ b/lib/network/modules/ManipulationSystem.js @@ -110,6 +110,7 @@ class ManipulationSystem { } } + enableEditMode() { this.editMode = true; @@ -143,7 +144,7 @@ class ManipulationSystem { // restore the state of any bound functions or events, remove control nodes, restore physics this._clean(); - // reset global letiables + // reset global variables this.manipulationDOM = {}; // if the gui is enabled, draw all elements. @@ -192,7 +193,7 @@ class ManipulationSystem { // remove buttons if (selectedTotalCount !== 0) { - if (selectedNodeCount === 1 && this.options.deleteNode !== false) { + if (selectedNodeCount > 0 && this.options.deleteNode !== false) { if (needSeperator === true) { this._createSeperator(4); } @@ -221,8 +222,6 @@ class ManipulationSystem { /** * Create the toolbar for adding Nodes - * - * @private */ addNodeMode() { // when using the gui, enable edit mode if it wasnt already. @@ -250,8 +249,6 @@ class ManipulationSystem { /** * call the bound function to handle the editing of the node. The node has to be selected. - * - * @private */ editNode() { // when using the gui, enable edit mode if it wasnt already. @@ -298,8 +295,6 @@ class ManipulationSystem { /** * create the toolbar to connect nodes - * - * @private */ addEdgeMode() { // when using the gui, enable edit mode if it wasnt already. @@ -334,11 +329,9 @@ class ManipulationSystem { /** * create the toolbar to edit edges - * - * @private */ editEdgeMode() { - // when using the gui, enable edit mode if it wasnt already. + // when using the gui, enable edit mode if it wasn't already. if (this.editMode !== true) { this.enableEditMode(); } @@ -376,15 +369,12 @@ class ManipulationSystem { // temporarily overload UI functions, cleaned up automatically because of _temporaryBindUI this._temporaryBindUI('onTouch', this._controlNodeTouch.bind(this)); // used to get the position - this._temporaryBindUI('onTap', () => { - }); // disabled - this._temporaryBindUI('onHold', () => { - }); // disabled + this._temporaryBindUI('onTap', () => {}); // disabled + this._temporaryBindUI('onHold', () => {}); // disabled this._temporaryBindUI('onDragStart', this._controlNodeDragStart.bind(this));// used to select control node this._temporaryBindUI('onDrag', this._controlNodeDrag.bind(this)); // used to drag control node this._temporaryBindUI('onDragEnd', this._controlNodeDragEnd.bind(this)); // used to connect or revert control nodes - this._temporaryBindUI('onMouseMove', () => { - }); // disabled + this._temporaryBindUI('onMouseMove', () => {}); // disabled // create function to position control nodes correctly on movement // automatically cleaned up because we use the temporary bind @@ -409,8 +399,6 @@ class ManipulationSystem { /** * delete everything in the selection - * - * @private */ deleteSelected() { // when using the gui, enable edit mode if it wasnt already. @@ -560,7 +548,11 @@ class ManipulationSystem { controlNodeStyle.x = x; controlNodeStyle.y = y; - return this.body.functions.createNode(controlNodeStyle); + // we have to define the bounding box in order for the nodes to be drawn immediately + let node = this.body.functions.createNode(controlNodeStyle); + node.shape.boundingBox = {left: x, right:x, top:y, bottom:y}; + + return node; } @@ -650,7 +642,7 @@ class ManipulationSystem { // remove the manipulation divs if (this.manipulationDiv) {this.canvas.frame.removeChild(this.manipulationDiv);} if (this.editModeDiv) {this.canvas.frame.removeChild(this.editModeDiv);} - if (this.closeDiv) {this.canvas.frame.removeChild(this.manipulationDiv);} + if (this.closeDiv) {this.canvas.frame.removeChild(this.closeDiv);} // set the references to undefined this.manipulationDiv = undefined; @@ -893,6 +885,11 @@ class ManipulationSystem { let pointerObj = this.selectionHandler._pointerToPositionObject(pointer); let edge = this.body.edges[this.edgeBeingEditedId]; + // if the node that was dragged is not a control node, return + if (this.selectedControlNode === undefined) { + return; + } + let overlappingNodeIds = this.selectionHandler._getAllNodesOverlappingWith(pointerObj); let node = undefined; for (let i = overlappingNodeIds.length-1; i >= 0; i--) { diff --git a/lib/network/modules/NodesHandler.js b/lib/network/modules/NodesHandler.js index ce5def8d7..f7f6d6503 100644 --- a/lib/network/modules/NodesHandler.js +++ b/lib/network/modules/NodesHandler.js @@ -87,13 +87,17 @@ class NodesHandler { }, shadow: { enabled: false, + color: 'rgba(0,0,0,0.5)', size: 10, x: 5, y: 5 }, shape: 'ellipse', shapeProperties: { - borderDashes: false + borderDashes: false, // only for borders + borderRadius: 6, // only for box shape + useImageSize: false, // only for image and circularImage shapes + useBorderWithImage: false // only for image shape }, size: 25, title: undefined, @@ -111,6 +115,10 @@ class NodesHandler { this.body.emitter.on('refreshNodes', this.refresh.bind(this)); this.body.emitter.on('refresh', this.refresh.bind(this)); this.body.emitter.on('destroy', () => { + util.forEach(this.nodesListeners, (callback, event) => { + if (this.body.data.nodes) + this.body.data.nodes.off(event, callback); + }); delete this.body.functions.createNode; delete this.nodesListeners.add; delete this.nodesListeners.update; @@ -132,7 +140,7 @@ class NodesHandler { } } - // update the shape size in all nodes + // update the font in all nodes if (options.font !== undefined) { Label.parseOptions(this.options.font, options); for (let nodeId in this.body.nodes) { @@ -290,7 +298,7 @@ class NodesHandler { } - refresh() { + refresh(clearPositions = false) { let nodes = this.body.nodes; for (let nodeId in nodes) { let node = undefined; @@ -299,7 +307,10 @@ class NodesHandler { } let data = this.body.data.nodes._data[nodeId]; if (node !== undefined && data !== undefined) { - node.setOptions({ fixed: false, x:null, y:null }); + if (clearPositions === true) { + node.setOptions({x:null, y:null}); + } + node.setOptions({ fixed: false }); node.setOptions(data); } } @@ -329,11 +340,9 @@ class NodesHandler { } } else { - for (let nodeId in this.body.nodes) { - if (this.body.nodes.hasOwnProperty(nodeId)) { - let node = this.body.nodes[nodeId]; - dataArray[nodeId] = { x: Math.round(node.x), y: Math.round(node.y) }; - } + for (let i = 0; i < this.body.nodeIndices.length; i++) { + let node = this.body.nodes[this.body.nodeIndices[i]]; + dataArray[this.body.nodeIndices[i]] = { x: Math.round(node.x), y: Math.round(node.y) }; } } return dataArray; @@ -352,7 +361,7 @@ class NodesHandler { if (dataset._data.hasOwnProperty(nodeId)) { let node = this.body.nodes[nodeId]; if (dataset._data[nodeId].x != Math.round(node.x) || dataset._data[nodeId].y != Math.round(node.y)) { - dataArray.push({ id: nodeId, x: Math.round(node.x), y: Math.round(node.y) }); + dataArray.push({ id: node.id, x: Math.round(node.x), y: Math.round(node.y) }); } } } @@ -383,13 +392,13 @@ class NodesHandler { let nodeObj = {}; // used to quickly check if node already exists for (let i = 0; i < node.edges.length; i++) { let edge = node.edges[i]; - if (edge.toId == nodeId) { // these are double equals since ids can be numeric or string + if (edge.toId == node.id) { // these are double equals since ids can be numeric or string if (nodeObj[edge.fromId] === undefined) { nodeList.push(edge.fromId); nodeObj[edge.fromId] = true; } } - else if (edge.fromId == nodeId) { // these are double equals since ids can be numeric or string + else if (edge.fromId == node.id) { // these are double equals since ids can be numeric or string if (nodeObj[edge.toId] === undefined) { nodeList.push(edge.toId); nodeObj[edge.toId] = true; @@ -414,11 +423,28 @@ class NodesHandler { } } else { - console.log("NodeId provided for getConnectedEdges does not exist. Provided: ", nodeId) + console.log("NodeId provided for getConnectedEdges does not exist. Provided: ", nodeId); } return edgeList; } + + /** + * Move a node. + * @param String nodeId + * @param Number x + * @param Number y + */ + moveNode(nodeId, x, y) { + if (this.body.nodes[nodeId] !== undefined) { + this.body.nodes[nodeId].x = Number(x); + this.body.nodes[nodeId].y = Number(y); + setTimeout(() => {this.body.emitter.emit("startSimulation")},0); + } + else { + console.log("Node id supplied to moveNode does not exist. Provided: ", nodeId); + } + } } export default NodesHandler; \ No newline at end of file diff --git a/lib/network/modules/PhysicsEngine.js b/lib/network/modules/PhysicsEngine.js index da86725e3..7d5389065 100644 --- a/lib/network/modules/PhysicsEngine.js +++ b/lib/network/modules/PhysicsEngine.js @@ -19,9 +19,15 @@ class PhysicsEngine { this.simulationInterval = 1000 / 60; this.requiresTimeout = true; this.previousStates = {}; + this.referenceState = {}; this.freezeCache = {}; this.renderTimer = undefined; - this.initialStabilizationEmitted = false; + + // parameters for the adaptive timestep + this.adaptiveTimestep = false; + this.adaptiveTimestepEnabled = false; + this.adaptiveCounter = 0; + this.adaptiveInterval = 3; this.stabilized = false; this.startedStabilization = false; @@ -66,7 +72,7 @@ class PhysicsEngine { damping: 0.09 }, maxVelocity: 50, - minVelocity: 0.1, // px/s + minVelocity: 0.75, // px/s solver: 'barnesHut', stabilization: { enabled: true, @@ -75,15 +81,19 @@ class PhysicsEngine { onlyDynamicEdges: false, fit: true }, - timestep: 0.5 + timestep: 0.5, + adaptiveTimestep: true }; util.extend(this.options, this.defaultOptions); + this.timestep = 0.5; + this.layoutFailed = false; this.bindEventListeners(); } bindEventListeners() { this.body.emitter.on('initPhysics', () => {this.initPhysics();}); + this.body.emitter.on('_layoutFailed', () => {this.layoutFailed = true;}); this.body.emitter.on('resetPhysics', () => {this.stopSimulation(); this.ready = false;}); this.body.emitter.on('disablePhysics', () => {this.physicsEnabled = false; this.stopSimulation();}); this.body.emitter.on('restorePhysics', () => { @@ -102,8 +112,21 @@ class PhysicsEngine { this.stopSimulation(false); this.body.emitter.off(); }); + // this event will trigger a rebuilding of the cache everything. Used when nodes or edges have been added or removed. + this.body.emitter.on("_dataChanged", () => { + // update shortcut lists + this.updatePhysicsData(); + }); + + // debug: show forces + // this.body.emitter.on("afterDrawing", (ctx) => {this._drawForces(ctx);}); } + + /** + * set the physics options + * @param options + */ setOptions(options) { if (options !== undefined) { if (options === false) { @@ -114,7 +137,7 @@ class PhysicsEngine { else { this.physicsEnabled = true; util.selectiveNotDeepExtend(['stabilization'], this.options, options); - util.mergeOptions(this.options, options, 'stabilization') + util.mergeOptions(this.options, options, 'stabilization'); if (options.enabled === undefined) { this.options.enabled = true; @@ -124,12 +147,18 @@ class PhysicsEngine { this.physicsEnabled = false; this.stopSimulation(); } + + // set the timestep + this.timestep = this.options.timestep; } } this.init(); } + /** + * configure the engine. + */ init() { var options; if (this.options.solver === 'forceAtlas2Based') { @@ -160,6 +189,10 @@ class PhysicsEngine { this.modelOptions = options; } + + /** + * initialize the engine + */ initPhysics() { if (this.physicsEnabled === true && this.options.enabled === true) { if (this.options.stabilization.enabled === true) { @@ -168,7 +201,7 @@ class PhysicsEngine { else { this.stabilized = false; this.ready = true; - this.body.emitter.emit('fit', {}, true); + this.body.emitter.emit('fit', {}, this.layoutFailed); // if the layout failed, we use the approximation for the zoom this.startSimulation(); } } @@ -185,6 +218,9 @@ class PhysicsEngine { if (this.physicsEnabled === true && this.options.enabled === true) { this.stabilized = false; + // when visible, adaptivity is disabled. + this.adaptiveTimestep = false; + // this sets the width of all nodes initially which could be required for the avoidOverlap this.body.emitter.emit("_resizeNodes"); if (this.viewFunction === undefined) { @@ -218,7 +254,7 @@ class PhysicsEngine { /** - * The viewFunction inserts this step into each renderloop. It calls the physics tick and handles the cleanup at stabilized. + * The viewFunction inserts this step into each render loop. It calls the physics tick and handles the cleanup at stabilized. * */ simulationStep() { @@ -236,23 +272,20 @@ class PhysicsEngine { } if (this.stabilized === true) { - if (this.stabilizationIterations > 1) { - // trigger the 'stabilized' event. - // The event is triggered on the next tick, to prevent the case that - // it is fired while initializing the Network, in which case you would not - // be able to catch it - this.startedStabilization = false; - //this._emitStabilized(); - } this.stopSimulation(); } } - _emitStabilized() { - if (this.stabilizationIterations > 1 || this.initialStabilizationEmitted === false) { - this.initialStabilizationEmitted = true; + + /** + * trigger the stabilized event. + * @private + */ + _emitStabilized(amountOfIterations = this.stabilizationIterations) { + if (this.stabilizationIterations > 1 || this.startedStabilization === true) { setTimeout(() => { - this.body.emitter.emit('stabilized', {iterations: this.stabilizationIterations}); + this.body.emitter.emit('stabilized', {iterations: amountOfIterations}); + this.startedStabilization = false; this.stabilizationIterations = 0; }, 0); } @@ -264,21 +297,74 @@ class PhysicsEngine { * @private */ physicsTick() { + // this is here to ensure that there is no start event when the network is already stable. + if (this.startedStabilization === false) { + this.body.emitter.emit('startStabilizing'); + this.startedStabilization = true; + } + if (this.stabilized === false) { - this.calculateForces(); - this.stabilized = this.moveNodes(); + // adaptivity means the timestep adapts to the situation, only applicable for stabilization + if (this.adaptiveTimestep === true && this.adaptiveTimestepEnabled === true) { + // this is the factor for increasing the timestep on success. + let factor = 1.2; + + // we assume the adaptive interval is + if (this.adaptiveCounter % this.adaptiveInterval === 0) { // we leave the timestep stable for "interval" iterations. + // first the big step and revert. Revert saves the reference state. + this.timestep = 2 * this.timestep; + this.calculateForces(); + this.moveNodes(); + this.revert(); + + // now the normal step. Since this is the last step, it is the more stable one and we will take this. + this.timestep = 0.5 * this.timestep; + + // since it's half the step, we do it twice. + this.calculateForces(); + this.moveNodes(); + this.calculateForces(); + this.moveNodes(); + + // we compare the two steps. if it is acceptable we double the step. + if (this._evaluateStepQuality() === true) { + this.timestep = factor * this.timestep; + } + else { + // if not, we decrease the step to a minimum of the options timestep. + // if the decreased timestep is smaller than the options step, we do not reset the counter + // we assume that the options timestep is stable enough. + if (this.timestep/factor < this.options.timestep) { + this.timestep = this.options.timestep; + } + else { + // if the timestep was larger than 2 times the option one we check the adaptivity again to ensure + // that large instabilities do not form. + this.adaptiveCounter = -1; // check again next iteration + this.timestep = Math.max(this.options.timestep, this.timestep/factor); + } + } + } + else { + // normal step, keeping timestep constant + this.calculateForces(); + this.moveNodes(); + } + + // increment the counter + this.adaptiveCounter += 1; + } + else { + // case for the static timestep, we reset it to the one in options and take a normal step. + this.timestep = this.options.timestep; + this.calculateForces(); + this.moveNodes(); + } // determine if the network has stabilzied if (this.stabilized === true) { this.revert(); } - else { - // this is here to ensure that there is no start event when the network is already stable. - if (this.startedStabilization === false) { - this.body.emitter.emit('startStabilizing'); - this.startedStabilization = true; - } - } this.stabilizationIterations++; } @@ -300,7 +386,7 @@ class PhysicsEngine { for (let nodeId in nodes) { if (nodes.hasOwnProperty(nodeId)) { if (nodes[nodeId].options.physics === true) { - this.physicsBody.physicsNodeIndices.push(nodeId); + this.physicsBody.physicsNodeIndices.push(nodes[nodeId].id); } } } @@ -309,7 +395,7 @@ class PhysicsEngine { for (let edgeId in edges) { if (edges.hasOwnProperty(edgeId)) { if (edges[edgeId].options.physics === true) { - this.physicsBody.physicsEdgeIndices.push(edgeId); + this.physicsBody.physicsEdgeIndices.push(edges[edgeId].id); } } } @@ -341,11 +427,15 @@ class PhysicsEngine { var nodeIds = Object.keys(this.previousStates); var nodes = this.body.nodes; var velocities = this.physicsBody.velocities; + this.referenceState = {}; for (let i = 0; i < nodeIds.length; i++) { let nodeId = nodeIds[i]; if (nodes[nodeId] !== undefined) { if (nodes[nodeId].options.physics === true) { + this.referenceState[nodeId] = { + positions: {x:nodes[nodeId].x, y:nodes[nodeId].y} + }; velocities[nodeId].x = this.previousStates[nodeId].vx; velocities[nodeId].y = this.previousStates[nodeId].vy; nodes[nodeId].x = this.previousStates[nodeId].x; @@ -359,34 +449,53 @@ class PhysicsEngine { } /** - * move the nodes one timestap and check if they are stabilized + * This compares the reference state to the current state + */ + _evaluateStepQuality() { + let dx, dy, dpos; + let nodes = this.body.nodes; + let reference = this.referenceState; + let posThreshold = 0.3; + + for (let nodeId in this.referenceState) { + if (this.referenceState.hasOwnProperty(nodeId) && nodes[nodeId] !== undefined) { + dx = nodes[nodeId].x - reference[nodeId].positions.x; + dy = nodes[nodeId].y - reference[nodeId].positions.y; + + dpos = Math.sqrt(Math.pow(dx,2) + Math.pow(dy,2)) + + if (dpos > posThreshold) { + return false; + } + } + } + return true; + } + + /** + * move the nodes one timestep and check if they are stabilized * @returns {boolean} */ moveNodes() { - var nodesPresent = false; var nodeIndices = this.physicsBody.physicsNodeIndices; var maxVelocity = this.options.maxVelocity ? this.options.maxVelocity : 1e9; - var stabilized = true; - var vminCorrected = this.options.minVelocity / Math.max(this.body.view.scale,0.05); + var maxNodeVelocity = 0; + var averageNodeVelocity = 0; + + // the velocity threshold (energy in the system) for the adaptivity toggle + var velocityAdaptiveThreshold = 5; for (let i = 0; i < nodeIndices.length; i++) { let nodeId = nodeIndices[i]; let nodeVelocity = this._performStep(nodeId, maxVelocity); // stabilized is true if stabilized is true and velocity is smaller than vmin --> all nodes must be stabilized - stabilized = nodeVelocity < vminCorrected && stabilized === true; - nodesPresent = true; + maxNodeVelocity = Math.max(maxNodeVelocity,nodeVelocity); + averageNodeVelocity += nodeVelocity; } - - if (nodesPresent === true) { - if (vminCorrected > 0.5*this.options.maxVelocity) { - return false; - } - else { - return stabilized; - } - } - return true; + // evaluating the stabilized and adaptiveTimestepEnabled conditions + this.adaptiveTimestepEnabled = (averageNodeVelocity/nodeIndices.length) < velocityAdaptiveThreshold; + this.stabilized = maxNodeVelocity < this.options.minVelocity; } @@ -399,10 +508,10 @@ class PhysicsEngine { * @private */ _performStep(nodeId,maxVelocity) { - var node = this.body.nodes[nodeId]; - var timestep = this.options.timestep; - var forces = this.physicsBody.forces; - var velocities = this.physicsBody.velocities; + let node = this.body.nodes[nodeId]; + let timestep = this.timestep; + let forces = this.physicsBody.forces; + let velocities = this.physicsBody.velocities; // store the state so we can revert this.previousStates[nodeId] = {x:node.x, y:node.y, vx:velocities[nodeId].x, vy:velocities[nodeId].y}; @@ -412,7 +521,7 @@ class PhysicsEngine { let ax = (forces[nodeId].x - dx) / node.options.mass; // acceleration velocities[nodeId].x += ax * timestep; // velocity velocities[nodeId].x = (Math.abs(velocities[nodeId].x) > maxVelocity) ? ((velocities[nodeId].x > 0) ? maxVelocity : -maxVelocity) : velocities[nodeId].x; - node.x += velocities[nodeId].x * timestep; // position + node.x += velocities[nodeId].x * timestep; // position } else { forces[nodeId].x = 0; @@ -424,14 +533,14 @@ class PhysicsEngine { let ay = (forces[nodeId].y - dy) / node.options.mass; // acceleration velocities[nodeId].y += ay * timestep; // velocity velocities[nodeId].y = (Math.abs(velocities[nodeId].y) > maxVelocity) ? ((velocities[nodeId].y > 0) ? maxVelocity : -maxVelocity) : velocities[nodeId].y; - node.y += velocities[nodeId].y * timestep; // position + node.y += velocities[nodeId].y * timestep; // position } else { forces[nodeId].y = 0; velocities[nodeId].y = 0; } - var totalVelocity = Math.sqrt(Math.pow(velocities[nodeId].x,2) + Math.pow(velocities[nodeId].y,2)); + let totalVelocity = Math.sqrt(Math.pow(velocities[nodeId].x,2) + Math.pow(velocities[nodeId].y,2)); return totalVelocity; } @@ -486,7 +595,6 @@ class PhysicsEngine { /** * Find a stable position for all nodes - * @private */ stabilize(iterations = this.options.stabilization.iterations) { if (typeof iterations !== 'number') { @@ -499,6 +607,8 @@ class PhysicsEngine { return; } + // enable adaptive timesteps + this.adaptiveTimestep = true && this.options.adaptiveTimestep; // this sets the width of all nodes initially which could be required for the avoidOverlap this.body.emitter.emit("_resizeNodes"); @@ -522,11 +632,21 @@ class PhysicsEngine { setTimeout(() => this._stabilizationBatch(),0); } + + /** + * One batch of stabilization + * @private + */ _stabilizationBatch() { + // this is here to ensure that there is at least one start event. + if (this.startedStabilization === false) { + this.body.emitter.emit('startStabilizing'); + this.startedStabilization = true; + } + var count = 0; while (this.stabilized === false && count < this.options.stabilization.updateInterval && this.stabilizationIterations < this.targetIterations) { this.physicsTick(); - this.stabilizationIterations++; count++; } @@ -539,6 +659,11 @@ class PhysicsEngine { } } + + /** + * Wrap up the stabilization, fit and emit the events. + * @private + */ _finalizeStabilization() { this.body.emitter.emit('_allowRedraw'); if (this.options.stabilization.fit === true) { @@ -548,7 +673,7 @@ class PhysicsEngine { if (this.options.stabilization.onlyDynamicEdges === true) { this._restoreFrozenNodes(); } - + this.body.emitter.emit('stabilizationIterationsDone'); this.body.emitter.emit('_requestRedraw'); @@ -561,6 +686,34 @@ class PhysicsEngine { this.ready = true; } + + + _drawForces(ctx) { + for (var i = 0; i < this.physicsBody.physicsNodeIndices.length; i++) { + let node = this.body.nodes[this.physicsBody.physicsNodeIndices[i]]; + let force = this.physicsBody.forces[this.physicsBody.physicsNodeIndices[i]]; + let factor = 20; + let colorFactor = 0.03; + let forceSize = Math.sqrt(Math.pow(force.x,2) + Math.pow(force.x,2)); + + let size = Math.min(Math.max(5,forceSize),15); + let arrowSize = 3*size; + + let color = util.HSVToHex((180 - Math.min(1,Math.max(0,colorFactor*forceSize))*180) / 360,1,1); + + ctx.lineWidth = size; + ctx.strokeStyle = color; + ctx.beginPath(); + ctx.moveTo(node.x,node.y); + ctx.lineTo(node.x+factor*force.x, node.y+factor*force.y); + ctx.stroke(); + + let angle = Math.atan2(force.y, force.x); + ctx.fillStyle = color; + ctx.arrow(node.x + factor*force.x + Math.cos(angle)*arrowSize, node.y + factor*force.y+Math.sin(angle)*arrowSize, angle, arrowSize); + ctx.fill(); + } + } } diff --git a/lib/network/modules/SelectionHandler.js b/lib/network/modules/SelectionHandler.js index 891ec7ebe..415b6ba80 100644 --- a/lib/network/modules/SelectionHandler.js +++ b/lib/network/modules/SelectionHandler.js @@ -159,7 +159,6 @@ class SelectionHandler { * * @param {{x: Number, y: Number}} pointer * @return {Node | undefined} node - * @private */ getNodeAt(pointer, returnNode = true) { // we first check if this is an navigation controls element @@ -217,7 +216,6 @@ class SelectionHandler { * * @param pointer * @returns {undefined} - * @private */ getEdgeAt(pointer, returnEdge = true) { let positionObject = this._pointerToPositionObject(pointer); @@ -277,6 +275,7 @@ class SelectionHandler { _removeFromSelection(obj) { if (obj instanceof Node) { delete this.selectionObj.nodes[obj.id]; + this._unselectConnectedEdges(obj); } else { delete this.selectionObj.edges[obj.id]; @@ -285,8 +284,6 @@ class SelectionHandler { /** * Unselect all. The selectionObj is useful for this. - * - * @private */ unselectAll() { for(let nodeId in this.selectionObj.nodes) { @@ -299,7 +296,7 @@ class SelectionHandler { this.selectionObj.edges[edgeId].unselect(); } } - + this.selectionObj = {nodes:{},edges:{}}; } @@ -575,7 +572,7 @@ class SelectionHandler { if (this.options.selectable === true) { for (let nodeId in this.selectionObj.nodes) { if (this.selectionObj.nodes.hasOwnProperty(nodeId)) { - idArray.push(nodeId); + idArray.push(this.selectionObj.nodes[nodeId].id); } } } @@ -593,13 +590,54 @@ class SelectionHandler { if (this.options.selectable === true) { for (let edgeId in this.selectionObj.edges) { if (this.selectionObj.edges.hasOwnProperty(edgeId)) { - idArray.push(edgeId); + idArray.push(this.selectionObj.edges[edgeId].id); } } } return idArray; } + /** + * Updates the current selection + * @param {{nodes: Array., edges: Array.}} Selection + * @param {Object} options Options + */ + setSelection(selection, options = {}) { + let i, id; + + if (!selection || (!selection.nodes && !selection.edges)) + throw 'Selection must be an object with nodes and/or edges properties'; + // first unselect any selected node, if option is true or undefined + if (options.unselectAll || options.unselectAll === undefined) { + this.unselectAll(); + } + if (selection.nodes) { + for (i = 0; i < selection.nodes.length; i++) { + id = selection.nodes[i]; + + let node = this.body.nodes[id]; + if (!node) { + throw new RangeError('Node with id "' + id + '" not found'); + } + // don't select edges with it + this.selectObject(node, options.highlightEdges); + } + } + + if (selection.edges) { + for (i = 0; i < selection.edges.length; i++) { + id = selection.edges[i]; + + let edge = this.body.edges[id]; + if (!edge) { + throw new RangeError('Edge with id "' + id + '" not found'); + } + this.selectObject(edge); + } + } + this.body.emitter.emit('_requestRedraw'); + } + /** * select zero or more nodes with the option to highlight edges @@ -608,24 +646,10 @@ class SelectionHandler { * @param {boolean} [highlightEdges] */ selectNodes(selection, highlightEdges = true) { - let i, id; - if (!selection || (selection.length === undefined)) throw 'Selection must be an array with ids'; - - // first unselect any selected node - this.unselectAll(); - - for (i = 0; i < selection.length; i++) { - id = selection[i]; - - let node = this.body.nodes[id]; - if (!node) { - throw new RangeError('Node with id "' + id + '" not found'); - } - this.selectObject(node,highlightEdges); - } - this.body.emitter.emit('_requestRedraw'); + + this.setSelection({nodes: selection}, {highlightEdges: highlightEdges}); } @@ -635,24 +659,10 @@ class SelectionHandler { * selected nodes. */ selectEdges(selection) { - let i, id; - if (!selection || (selection.length === undefined)) throw 'Selection must be an array with ids'; - - // first unselect any selected objects - this.unselectAll(); - - for (i = 0; i < selection.length; i++) { - id = selection[i]; - - let edge = this.body.edges[id]; - if (!edge) { - throw new RangeError('Edge with id "' + id + '" not found'); - } - this.selectObject(edge); - } - this.body.emitter.emit('_requestRedraw'); + + this.setSelection({edges: selection}); } /** diff --git a/lib/network/modules/View.js b/lib/network/modules/View.js index 586831156..d1004c805 100644 --- a/lib/network/modules/View.js +++ b/lib/network/modules/View.js @@ -1,4 +1,6 @@ -var util = require('../../util'); +let util = require('../../util'); + +import NetworkUtil from '../NetworkUtil'; class View { constructor(body, canvas) { @@ -29,80 +31,25 @@ class View { } - /** - * Find the center position of the network - * @private - */ - _getRange(specificNodes = []) { - var minY = 1e9, maxY = -1e9, minX = 1e9, maxX = -1e9, node; - if (specificNodes.length > 0) { - for (var i = 0; i < specificNodes.length; i++) { - node = this.body.nodes[specificNodes[i]]; - if (minX > (node.shape.boundingBox.left)) { - minX = node.shape.boundingBox.left; - } - if (maxX < (node.shape.boundingBox.right)) { - maxX = node.shape.boundingBox.right; - } - if (minY > (node.shape.boundingBox.top)) { - minY = node.shape.boundingBox.top; - } // top is negative, bottom is positive - if (maxY < (node.shape.boundingBox.bottom)) { - maxY = node.shape.boundingBox.bottom; - } // top is negative, bottom is positive - } - } - else { - for (var i = 0; i < this.body.nodeIndices.length; i++) { - node = this.body.nodes[this.body.nodeIndices[i]]; - if (minX > (node.shape.boundingBox.left)) { - minX = node.shape.boundingBox.left; - } - if (maxX < (node.shape.boundingBox.right)) { - maxX = node.shape.boundingBox.right; - } - if (minY > (node.shape.boundingBox.top)) { - minY = node.shape.boundingBox.top; - } // top is negative, bottom is positive - if (maxY < (node.shape.boundingBox.bottom)) { - maxY = node.shape.boundingBox.bottom; - } // top is negative, bottom is positive - } - } - - if (minX === 1e9 && maxX === -1e9 && minY === 1e9 && maxY === -1e9) { - minY = 0, maxY = 0, minX = 0, maxX = 0; - } - return {minX: minX, maxX: maxX, minY: minY, maxY: maxY}; - } - - - /** - * @param {object} range = {minX: minX, maxX: maxX, minY: minY, maxY: maxY}; - * @returns {{x: number, y: number}} - * @private - */ - _findCenter(range) { - return {x: (0.5 * (range.maxX + range.minX)), - y: (0.5 * (range.maxY + range.minY))}; - } - - /** * This function zooms out to fit all data on screen based on amount of nodes * @param {Object} Options * @param {Boolean} [initialZoom] | zoom based on fitted formula or range, true = fitted, default = false; */ fit(options = {nodes:[]}, initialZoom = false) { - var range; - var zoomLevel; + let range; + let zoomLevel; + if (options.nodes === undefined || options.nodes.length === 0) { + options.nodes = this.body.nodeIndices; + } + if (initialZoom === true) { // check if more than half of the nodes have a predefined position. If so, we use the range, not the approximation. - var positionDefined = 0; - for (var nodeId in this.body.nodes) { + let positionDefined = 0; + for (let nodeId in this.body.nodes) { if (this.body.nodes.hasOwnProperty(nodeId)) { - var node = this.body.nodes[nodeId]; + let node = this.body.nodes[nodeId]; if (node.predefinedPosition === true) { positionDefined += 1; } @@ -113,24 +60,24 @@ class View { return; } - range = this._getRange(options.nodes); + range = NetworkUtil.getRange(this.body.nodes, options.nodes); - var numberOfNodes = this.body.nodeIndices.length; + let numberOfNodes = this.body.nodeIndices.length; zoomLevel = 12.662 / (numberOfNodes + 7.4147) + 0.0964822; // this is obtained from fitting a dataset from 5 points with scale levels that looked good. // correct for larger canvasses. - var factor = Math.min(this.canvas.frame.canvas.clientWidth / 600, this.canvas.frame.canvas.clientHeight / 600); + let factor = Math.min(this.canvas.frame.canvas.clientWidth / 600, this.canvas.frame.canvas.clientHeight / 600); zoomLevel *= factor; } else { this.body.emitter.emit("_resizeNodes"); - range = this._getRange(options.nodes); + range = NetworkUtil.getRange(this.body.nodes, options.nodes); - var xDistance = Math.abs(range.maxX - range.minX) * 1.1; - var yDistance = Math.abs(range.maxY - range.minY) * 1.1; + let xDistance = Math.abs(range.maxX - range.minX) * 1.1; + let yDistance = Math.abs(range.maxY - range.minY) * 1.1; - var xZoomLevel = this.canvas.frame.canvas.clientWidth / xDistance; - var yZoomLevel = this.canvas.frame.canvas.clientHeight / yDistance; + let xZoomLevel = this.canvas.frame.canvas.clientWidth / xDistance; + let yZoomLevel = this.canvas.frame.canvas.clientHeight / yDistance; zoomLevel = (xZoomLevel <= yZoomLevel) ? xZoomLevel : yZoomLevel; } @@ -142,8 +89,8 @@ class View { zoomLevel = 1.0; } - var center = this._findCenter(range); - var animationOptions = {position: center, scale: zoomLevel, animation: options.animation}; + let center = NetworkUtil.findCenter(range); + let animationOptions = {position: center, scale: zoomLevel, animation: options.animation}; this.moveTo(animationOptions); } @@ -157,7 +104,7 @@ class View { */ focus(nodeId, options = {}) { if (this.body.nodes[nodeId] !== undefined) { - var nodePosition = {x: this.body.nodes[nodeId].x, y: this.body.nodes[nodeId].y}; + let nodePosition = {x: this.body.nodes[nodeId].x, y: this.body.nodes[nodeId].y}; options.position = nodePosition; options.lockedOnNode = nodeId; @@ -229,9 +176,9 @@ class View { // set the scale so the viewCenter is based on the correct zoom level. This is overridden in the transitionRedraw // but at least then we'll have the target transition this.body.view.scale = this.targetScale; - var viewCenter = this.canvas.DOMtoCanvas({x: 0.5 * this.canvas.frame.canvas.clientWidth, y: 0.5 * this.canvas.frame.canvas.clientHeight}); + let viewCenter = this.canvas.DOMtoCanvas({x: 0.5 * this.canvas.frame.canvas.clientWidth, y: 0.5 * this.canvas.frame.canvas.clientHeight}); - var distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node + let distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node x: viewCenter.x - options.position.x, y: viewCenter.y - options.position.y }; @@ -268,14 +215,14 @@ class View { * @private */ _lockedRedraw() { - var nodePosition = {x: this.body.nodes[this.lockedOnNodeId].x, y: this.body.nodes[this.lockedOnNodeId].y}; - var viewCenter = this.canvas.DOMtoCanvas({x: 0.5 * this.canvas.frame.canvas.clientWidth, y: 0.5 * this.canvas.frame.canvas.clientHeight}); - var distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node + let nodePosition = {x: this.body.nodes[this.lockedOnNodeId].x, y: this.body.nodes[this.lockedOnNodeId].y}; + let viewCenter = this.canvas.DOMtoCanvas({x: 0.5 * this.canvas.frame.canvas.clientWidth, y: 0.5 * this.canvas.frame.canvas.clientHeight}); + let distanceFromCenter = { // offset from view, distance view has to change by these x and y to center the node x: viewCenter.x - nodePosition.x, y: viewCenter.y - nodePosition.y }; - var sourceTranslation = this.body.view.translation; - var targetTranslation = { + let sourceTranslation = this.body.view.translation; + let targetTranslation = { x: sourceTranslation.x + distanceFromCenter.x * this.body.view.scale + this.lockedOnNodeOffset.x, y: sourceTranslation.y + distanceFromCenter.y * this.body.view.scale + this.lockedOnNodeOffset.y }; @@ -300,7 +247,7 @@ class View { this.easingTime += this.animationSpeed; this.easingTime = finished === true ? 1.0 : this.easingTime; - var progress = util.easingFunctions[this.animationEasingFunction](this.easingTime); + let progress = util.easingFunctions[this.animationEasingFunction](this.easingTime); this.body.view.scale = this.sourceScale + (this.targetScale - this.sourceScale) * progress; this.body.view.translation = { diff --git a/lib/network/modules/components/Edge.js b/lib/network/modules/components/Edge.js index ded3d0391..01c84829d 100644 --- a/lib/network/modules/components/Edge.js +++ b/lib/network/modules/components/Edge.js @@ -1,6 +1,7 @@ var util = require('../../../util'); import Label from './shared/Label' +import CubicBezierEdge from './edges/CubicBezierEdge' import BezierEdgeDynamic from './edges/BezierEdgeDynamic' import BezierEdgeStatic from './edges/BezierEdgeStatic' import StraightEdge from './edges/StraightEdge' @@ -26,6 +27,7 @@ class Edge { throw "No body provided"; } this.options = util.bridgeObject(globalOptions); + this.globalOptions = globalOptions; this.body = body; // initialize variables @@ -64,7 +66,7 @@ class Edge { } this.colorDirty = true; - Edge.parseOptions(this.options, options, true); + Edge.parseOptions(this.options, options, true, this.globalOptions); if (options.id !== undefined) {this.id = options.id;} if (options.from !== undefined) {this.fromId = options.from;} @@ -91,8 +93,9 @@ class Edge { return dataChanged; } - static parseOptions(parentOptions, newOptions, allowDeletion = false) { + static parseOptions(parentOptions, newOptions, allowDeletion = false, globalOptions = {}) { var fields = [ + 'arrowStrikethrough', 'id', 'from', 'hidden', @@ -103,6 +106,7 @@ class Edge { 'line', 'opacity', 'physics', + 'scaling', 'selectionWidth', 'selfReferenceSize', 'to', @@ -114,29 +118,27 @@ class Edge { // only deep extend the items in the field array. These do not have shorthand. util.selectiveDeepExtend(fields, parentOptions, newOptions, allowDeletion); - util.mergeOptions(parentOptions, newOptions, 'smooth'); - util.mergeOptions(parentOptions, newOptions, 'shadow'); + util.mergeOptions(parentOptions, newOptions, 'smooth', allowDeletion, globalOptions); + util.mergeOptions(parentOptions, newOptions, 'shadow', allowDeletion, globalOptions); if (newOptions.dashes !== undefined && newOptions.dashes !== null) { parentOptions.dashes = newOptions.dashes; } else if (allowDeletion === true && newOptions.dashes === null) { - parentOptions.dashes = undefined; - delete parentOptions.dashes; + parentOptions.dashes = Object.create(globalOptions.dashes); // this sets the pointer of the option back to the global option. } // set the scaling newOptions if (newOptions.scaling !== undefined && newOptions.scaling !== null) { if (newOptions.scaling.min !== undefined) {parentOptions.scaling.min = newOptions.scaling.min;} if (newOptions.scaling.max !== undefined) {parentOptions.scaling.max = newOptions.scaling.max;} - util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label'); + util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label', allowDeletion, globalOptions.scaling); } else if (allowDeletion === true && newOptions.scaling === null) { - parentOptions.scaling = undefined; - delete parentOptions.scaling; + parentOptions.scaling = Object.create(globalOptions.scaling); // this sets the pointer of the option back to the global option. } - // hanlde multiple input cases for arrows + // handle multiple input cases for arrows if (newOptions.arrows !== undefined && newOptions.arrows !== null) { if (typeof newOptions.arrows === 'string') { let arrows = newOptions.arrows.toLowerCase(); @@ -145,21 +147,22 @@ class Edge { if (arrows.indexOf("from") != -1) {parentOptions.arrows.from.enabled = true;} } else if (typeof newOptions.arrows === 'object') { - util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'to'); - util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'middle'); - util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'from'); + util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'to', allowDeletion, globalOptions.arrows); + util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'middle', allowDeletion, globalOptions.arrows); + util.mergeOptions(parentOptions.arrows, newOptions.arrows, 'from', allowDeletion, globalOptions.arrows); } else { throw new Error("The arrow newOptions can only be an object or a string. Refer to the documentation. You used:" + JSON.stringify(newOptions.arrows)); } } else if (allowDeletion === true && newOptions.arrows === null) { - parentOptions.arrows = undefined; - delete parentOptions.arrows; + parentOptions.arrows = Object.create(globalOptions.arrows); // this sets the pointer of the option back to the global option. } - // hanlde multiple input cases for color + // handle multiple input cases for color if (newOptions.color !== undefined && newOptions.color !== null) { + // make a copy of the parent object in case this is referring to the global one (due to object create once, then update) + parentOptions.color = util.deepExtend({}, parentOptions.color, true); if (util.isString(newOptions.color)) { parentOptions.color.color = newOptions.color; parentOptions.color.highlight = newOptions.color; @@ -180,14 +183,16 @@ class Edge { } } else if (allowDeletion === true && newOptions.color === null) { - parentOptions.color = undefined; - delete parentOptions.color; + parentOptions.color = util.bridgeObject(globalOptions.color); // set the object back to the global options } // handle the font settings - if (newOptions.font !== undefined) { + if (newOptions.font !== undefined && newOptions.font !== null) { Label.parseOptions(parentOptions.font, newOptions); } + else if (allowDeletion === true && newOptions.font === null) { + parentOptions.font = util.bridgeObject(globalOptions.font); // set the object back to the global options + } } @@ -208,13 +213,15 @@ class Edge { updateEdgeType() { let dataChanged = false; let changeInType = true; + let smooth = this.options.smooth; if (this.edgeType !== undefined) { - if (this.edgeType instanceof BezierEdgeDynamic && this.options.smooth.enabled === true && this.options.smooth.type === 'dynamic') {changeInType = false;} - if (this.edgeType instanceof BezierEdgeStatic && this.options.smooth.enabled === true && this.options.smooth.type !== 'dynamic') {changeInType = false;} - if (this.edgeType instanceof StraightEdge && this.options.smooth.enabled === false) {changeInType = false;} + if (this.edgeType instanceof BezierEdgeDynamic && smooth.enabled === true && smooth.type === 'dynamic') {changeInType = false;} + if (this.edgeType instanceof CubicBezierEdge && smooth.enabled === true && smooth.type === 'cubicBezier') {changeInType = false;} + if (this.edgeType instanceof BezierEdgeStatic && smooth.enabled === true && smooth.type !== 'dynamic' && smooth.type !== 'cubicBezier') {changeInType = false;} + if (this.edgeType instanceof StraightEdge && smooth.enabled === false) {changeInType = false;} if (changeInType === true) { - dataChanged = this.edgeType.cleanup(); + dataChanged = this.cleanup(); } } @@ -224,6 +231,9 @@ class Edge { dataChanged = true; this.edgeType = new BezierEdgeDynamic(this.options, this.body, this.labelModule); } + else if (this.options.smooth.type === 'cubicBezier') { + this.edgeType = new CubicBezierEdge(this.options, this.body, this.labelModule); + } else { this.edgeType = new BezierEdgeStatic(this.options, this.body, this.labelModule); } @@ -241,15 +251,6 @@ class Edge { } - /** - * Enable or disable the physics. - * @param status - */ - togglePhysics(status) { - this.options.physics = status; - this.edgeType.togglePhysics(status); - } - /** * Connect an edge to its nodes */ @@ -346,6 +347,7 @@ class Edge { } this._setInteractionWidths(); + this.updateLabelModule(); } _setInteractionWidths() { @@ -372,17 +374,45 @@ class Edge { * @param {CanvasRenderingContext2D} ctx */ draw(ctx) { - let via = this.edgeType.drawLine(ctx, this.selected, this.hover); - this.drawArrows(ctx, via); - this.drawLabel (ctx, via); + // get the via node from the edge type + let viaNode = this.edgeType.getViaNode(); + let arrowData = {}; + + // restore edge targets to defaults + this.edgeType.fromPoint = this.from; + this.edgeType.toPoint = this.to; + + // from and to arrows give a different end point for edges. we set them here + if (this.options.arrows.from.enabled === true) { + arrowData.from = this.edgeType.getArrowData(ctx,'from', viaNode, this.selected, this.hover); + if (this.options.arrowStrikethrough === false) + this.edgeType.fromPoint = arrowData.from.core; + } + if (this.options.arrows.to.enabled === true) { + arrowData.to = this.edgeType.getArrowData(ctx,'to', viaNode, this.selected, this.hover); + if (this.options.arrowStrikethrough === false) + this.edgeType.toPoint = arrowData.to.core; + } + + // the middle arrow depends on the line, which can depend on the to and from arrows so we do this one lastly. + if (this.options.arrows.middle.enabled === true) { + arrowData.middle = this.edgeType.getArrowData(ctx,'middle', viaNode, this.selected, this.hover); + } + + // draw everything + this.edgeType.drawLine(ctx, this.selected, this.hover, viaNode); + this.drawArrows(ctx, arrowData); + this.drawLabel (ctx, viaNode); } - drawArrows(ctx, viaNode) { - if (this.options.arrows.from.enabled === true) {this.edgeType.drawArrowHead(ctx,'from', viaNode, this.selected, this.hover);} - if (this.options.arrows.middle.enabled === true) {this.edgeType.drawArrowHead(ctx,'middle', viaNode, this.selected, this.hover);} - if (this.options.arrows.to.enabled === true) {this.edgeType.drawArrowHead(ctx,'to', viaNode, this.selected, this.hover);} + + drawArrows(ctx, arrowData) { + if (this.options.arrows.from.enabled === true) {this.edgeType.drawArrowHead(ctx, this.selected, this.hover, arrowData.from);} + if (this.options.arrows.middle.enabled === true) {this.edgeType.drawArrowHead(ctx, this.selected, this.hover, arrowData.middle);} + if (this.options.arrows.to.enabled === true) {this.edgeType.drawArrowHead(ctx, this.selected, this.hover, arrowData.to);} } + drawLabel(ctx, viaNode) { if (this.options.label !== undefined) { // set style @@ -495,6 +525,15 @@ class Edge { unselect() { this.selected = false; } + + + /** + * cleans all required things on delete + * @returns {*} + */ + cleanup() { + return this.edgeType.cleanup(); + } } export default Edge; \ No newline at end of file diff --git a/lib/network/modules/components/Node.js b/lib/network/modules/components/Node.js index d1567e67b..79e32375c 100644 --- a/lib/network/modules/components/Node.js +++ b/lib/network/modules/components/Node.js @@ -48,6 +48,7 @@ import {printStyle} from "../../../shared/Validator"; class Node { constructor(options, body, imagelist, grouplist, globalOptions) { this.options = util.bridgeObject(globalOptions); + this.globalOptions = globalOptions; this.body = body; this.edges = []; // all edges connected to this node @@ -93,14 +94,6 @@ class Node { } } - /** - * Enable or disable the physics. - * @param status - */ - togglePhysics(status) { - this.options.physics = status; - } - /** * Set or overwrite options for the node @@ -108,6 +101,7 @@ class Node { * @param {Object} constants and object with default, global options */ setOptions(options) { + let currentShape = this.options.shape; if (!options) { return; } @@ -132,7 +126,6 @@ class Node { if (options.size !== undefined) {this.baseSize = options.size;} if (options.value !== undefined) {options.value = parseFloat(options.value);} - // copy group options if (typeof options.group === 'number' || (typeof options.group === 'string' && options.group != '')) { var groupObj = this.grouplist.get(options.group); @@ -142,7 +135,7 @@ class Node { } // this transforms all shorthands into fully defined options - Node.parseOptions(this.options, options, true); + Node.parseOptions(this.options, options, true, this.globalOptions); // load the images if (this.options.image !== undefined) { @@ -154,11 +147,8 @@ class Node { } } - this.updateShape(); this.updateLabelModule(); - - // reset the size of the node, this can be changed - this._reset(); + this.updateShape(currentShape); if (options.hidden !== undefined || options.physics !== undefined) { return true; @@ -172,8 +162,10 @@ class Node { * Static so it can also be used by the handler. * @param parentOptions * @param newOptions + * @param allowDeletion + * @param globalOptions */ - static parseOptions(parentOptions, newOptions, allowDeletion = false) { + static parseOptions(parentOptions, newOptions, allowDeletion = false, globalOptions = {}) { var fields = [ 'color', 'font', @@ -183,7 +175,7 @@ class Node { util.selectiveNotDeepExtend(fields, parentOptions, newOptions, allowDeletion); // merge the shadow options into the parent. - util.mergeOptions(parentOptions, newOptions, 'shadow'); + util.mergeOptions(parentOptions, newOptions, 'shadow', allowDeletion, globalOptions); // individual shape newOptions if (newOptions.color !== undefined && newOptions.color !== null) { @@ -191,8 +183,7 @@ class Node { util.fillIfDefined(parentOptions.color, parsedColor); } else if (allowDeletion === true && newOptions.color === null) { - parentOptions.color = undefined; - delete parentOptions.color; + parentOptions.color = util.bridgeObject(globalOptions.color); // set the object back to the global options } // handle the fixed options @@ -212,13 +203,16 @@ class Node { } // handle the font options - if (newOptions.font !== undefined) { + if (newOptions.font !== undefined && newOptions.font !== null) { Label.parseOptions(parentOptions.font, newOptions); } + else if (allowDeletion === true && newOptions.font === null) { + parentOptions.font = util.bridgeObject(globalOptions.font); // set the object back to the global options + } // handle the scaling options, specifically the label part if (newOptions.scaling !== undefined) { - util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label'); + util.mergeOptions(parentOptions.scaling, newOptions.scaling, 'label', allowDeletion, globalOptions.scaling); } } @@ -232,54 +226,59 @@ class Node { } } - updateShape() { - // choose draw method depending on the shape - switch (this.options.shape) { - case 'box': - this.shape = new Box(this.options, this.body, this.labelModule); - break; - case 'circle': - this.shape = new Circle(this.options, this.body, this.labelModule); - break; - case 'circularImage': - this.shape = new CircularImage(this.options, this.body, this.labelModule, this.imageObj); - break; - case 'database': - this.shape = new Database(this.options, this.body, this.labelModule); - break; - case 'diamond': - this.shape = new Diamond(this.options, this.body, this.labelModule); - break; - case 'dot': - this.shape = new Dot(this.options, this.body, this.labelModule); - break; - case 'ellipse': - this.shape = new Ellipse(this.options, this.body, this.labelModule); - break; - case 'icon': - this.shape = new Icon(this.options, this.body, this.labelModule); - break; - case 'image': - this.shape = new Image(this.options, this.body, this.labelModule, this.imageObj); - break; - case 'square': - this.shape = new Square(this.options, this.body, this.labelModule); - break; - case 'star': - this.shape = new Star(this.options, this.body, this.labelModule); - break; - case 'text': - this.shape = new Text(this.options, this.body, this.labelModule); - break; - case 'triangle': - this.shape = new Triangle(this.options, this.body, this.labelModule); - break; - case 'triangleDown': - this.shape = new TriangleDown(this.options, this.body, this.labelModule); - break; - default: - this.shape = new Ellipse(this.options, this.body, this.labelModule); - break; + updateShape(currentShape) { + if (currentShape === this.options.shape && this.shape) { + this.shape.setOptions(this.options, this.imageObj); + } + else { + // choose draw method depending on the shape + switch (this.options.shape) { + case 'box': + this.shape = new Box(this.options, this.body, this.labelModule); + break; + case 'circle': + this.shape = new Circle(this.options, this.body, this.labelModule); + break; + case 'circularImage': + this.shape = new CircularImage(this.options, this.body, this.labelModule, this.imageObj); + break; + case 'database': + this.shape = new Database(this.options, this.body, this.labelModule); + break; + case 'diamond': + this.shape = new Diamond(this.options, this.body, this.labelModule); + break; + case 'dot': + this.shape = new Dot(this.options, this.body, this.labelModule); + break; + case 'ellipse': + this.shape = new Ellipse(this.options, this.body, this.labelModule); + break; + case 'icon': + this.shape = new Icon(this.options, this.body, this.labelModule); + break; + case 'image': + this.shape = new Image(this.options, this.body, this.labelModule, this.imageObj); + break; + case 'square': + this.shape = new Square(this.options, this.body, this.labelModule); + break; + case 'star': + this.shape = new Star(this.options, this.body, this.labelModule); + break; + case 'text': + this.shape = new Text(this.options, this.body, this.labelModule); + break; + case 'triangle': + this.shape = new Triangle(this.options, this.body, this.labelModule); + break; + case 'triangleDown': + this.shape = new TriangleDown(this.options, this.body, this.labelModule); + break; + default: + this.shape = new Ellipse(this.options, this.body, this.labelModule); + break; + } } this._reset(); } @@ -382,6 +381,8 @@ class Node { this.options.size = this.baseSize; this.options.font.size = this.baseFontSize; } + + this.updateLabelModule(); } @@ -433,14 +434,12 @@ class Node { */ isBoundingBoxOverlappingWith(obj) { return ( - this.shape.boundingBox.left < obj.right && - this.shape.boundingBox.right > obj.left && - this.shape.boundingBox.top < obj.bottom && - this.shape.boundingBox.bottom > obj.top + this.shape.boundingBox.left < obj.right && + this.shape.boundingBox.right > obj.left && + this.shape.boundingBox.top < obj.bottom && + this.shape.boundingBox.bottom > obj.top ); } - - } export default Node; diff --git a/lib/network/modules/components/algorithms/FloydWarshall.js b/lib/network/modules/components/algorithms/FloydWarshall.js new file mode 100644 index 000000000..e0d3f92d4 --- /dev/null +++ b/lib/network/modules/components/algorithms/FloydWarshall.js @@ -0,0 +1,49 @@ +/** + * Created by Alex on 10-Aug-15. + */ + + +class FloydWarshall { + constructor(){} + + getDistances(body, nodesArray, edgesArray) { + let D_matrix = {}; + let edges = body.edges; + + // prepare matrix with large numbers + for (let i = 0; i < nodesArray.length; i++) { + D_matrix[nodesArray[i]] = {}; + D_matrix[nodesArray[i]] = {}; + for (let j = 0; j < nodesArray.length; j++) { + D_matrix[nodesArray[i]][nodesArray[j]] = (i == j ? 0 : 1e9); + D_matrix[nodesArray[i]][nodesArray[j]] = (i == j ? 0 : 1e9); + } + } + + // put the weights for the edges in. This assumes unidirectionality. + for (let i = 0; i < edgesArray.length; i++) { + let edge = edges[edgesArray[i]]; + // edge has to be connected if it counts to the distances. If it is connected to inner clusters it will crash so we also check if it is in the D_matrix + if (edge.connected === true && D_matrix[edge.fromId] !== undefined && D_matrix[edge.toId] !== undefined) { + D_matrix[edge.fromId][edge.toId] = 1; + D_matrix[edge.toId][edge.fromId] = 1; + } + } + + let nodeCount = nodesArray.length; + + // Adapted FloydWarshall based on unidirectionality to greatly reduce complexity. + for (let k = 0; k < nodeCount; k++) { + for (let i = 0; i < nodeCount-1; i++) { + for (let j = i+1; j < nodeCount; j++) { + D_matrix[nodesArray[i]][nodesArray[j]] = Math.min(D_matrix[nodesArray[i]][nodesArray[j]],D_matrix[nodesArray[i]][nodesArray[k]] + D_matrix[nodesArray[k]][nodesArray[j]]) + D_matrix[nodesArray[j]][nodesArray[i]] = D_matrix[nodesArray[i]][nodesArray[j]]; + } + } + } + + return D_matrix; + } +} + +export default FloydWarshall; \ No newline at end of file diff --git a/lib/network/modules/components/edges/BezierEdgeDynamic.js b/lib/network/modules/components/edges/BezierEdgeDynamic.js index 1e52a844f..0108d50a3 100644 --- a/lib/network/modules/components/edges/BezierEdgeDynamic.js +++ b/lib/network/modules/components/edges/BezierEdgeDynamic.js @@ -4,13 +4,32 @@ class BezierEdgeDynamic extends BezierEdgeBase { constructor(options, body, labelModule) { //this.via = undefined; // Here for completeness but not allowed to defined before super() is invoked. super(options, body, labelModule); // --> this calls the setOptions below + this._boundFunction = () => {this.positionBezierNode();}; + this.body.emitter.on("_repositionBezierNodes", this._boundFunction); } setOptions(options) { + // check if the physics has changed. + let physicsChange = false; + if (this.options.physics !== options.physics) { + physicsChange = true; + } + + // set the options and the to and from nodes this.options = options; this.id = this.options.id; + this.from = this.body.nodes[this.options.from]; + this.to = this.body.nodes[this.options.to]; + + // setup the support node and connect this.setupSupportNode(); this.connect(); + + // when we change the physics state of the edge, we reposition the support node. + if (physicsChange === true) { + this.via.setOptions({physics: this.options.physics}) + this.positionBezierNode(); + } } connect() { @@ -20,7 +39,7 @@ class BezierEdgeDynamic extends BezierEdgeBase { this.via.setOptions({physics:false}) } else { - // fix weird behaviour where a selfreferencing node has physics enabled + // fix weird behaviour where a self referencing node has physics enabled if (this.from.id === this.to.id) { this.via.setOptions({physics: false}) } @@ -30,7 +49,12 @@ class BezierEdgeDynamic extends BezierEdgeBase { } } + /** + * remove the support nodes + * @returns {boolean} + */ cleanup() { + this.body.emitter.off("_repositionBezierNodes", this._boundFunction); if (this.via !== undefined) { delete this.body.nodes[this.via.id]; this.via = undefined; @@ -39,11 +63,6 @@ class BezierEdgeDynamic extends BezierEdgeBase { return false; } - togglePhysics(status) { - this.via.setOptions({physics:status}); - this.positionBezierNode(); - } - /** * Bezier curves require an anchor point to calculate the smooth flow. These points are nodes. These nodes are invisible but * are used for the force calculation. @@ -83,15 +102,24 @@ class BezierEdgeDynamic extends BezierEdgeBase { * @param {CanvasRenderingContext2D} ctx * @private */ - _line(ctx) { + _line(ctx, viaNode) { // draw a straight line ctx.beginPath(); - ctx.moveTo(this.from.x, this.from.y); - ctx.quadraticCurveTo(this.via.x, this.via.y, this.to.x, this.to.y); + ctx.moveTo(this.fromPoint.x, this.fromPoint.y); + // fallback to normal straight edges + if (viaNode.x === undefined) { + ctx.lineTo(this.toPoint.x, this.toPoint.y); + } + else { + ctx.quadraticCurveTo(viaNode.x, viaNode.y, this.toPoint.x, this.toPoint.y); + } // draw shadow if enabled this.enableShadow(ctx); ctx.stroke(); this.disableShadow(ctx); + } + + getViaNode() { return this.via; } @@ -99,14 +127,14 @@ class BezierEdgeDynamic extends BezierEdgeBase { /** * Combined function of pointOnLine and pointOnBezier. This gives the coordinates of a point on the line at a certain percentage of the way * @param percentage - * @param via + * @param viaNode * @returns {{x: number, y: number}} * @private */ - getPoint(percentage) { + getPoint(percentage, viaNode = this.via) { let t = percentage; - let x = Math.pow(1 - t, 2) * this.from.x + (2 * t * (1 - t)) * this.via.x + Math.pow(t, 2) * this.to.x; - let y = Math.pow(1 - t, 2) * this.from.y + (2 * t * (1 - t)) * this.via.y + Math.pow(t, 2) * this.to.y; + let x = Math.pow(1 - t, 2) * this.fromPoint.x + (2 * t * (1 - t)) * viaNode.x + Math.pow(t, 2) * this.toPoint.x; + let y = Math.pow(1 - t, 2) * this.fromPoint.y + (2 * t * (1 - t)) * viaNode.y + Math.pow(t, 2) * this.toPoint.y; return {x: x, y: y}; } diff --git a/lib/network/modules/components/edges/BezierEdgeStatic.js b/lib/network/modules/components/edges/BezierEdgeStatic.js index 9b3619b0a..d70ed7c0d 100644 --- a/lib/network/modules/components/edges/BezierEdgeStatic.js +++ b/lib/network/modules/components/edges/BezierEdgeStatic.js @@ -10,28 +10,34 @@ class BezierEdgeStatic extends BezierEdgeBase { * @param {CanvasRenderingContext2D} ctx * @private */ - _line(ctx) { + _line(ctx, viaNode) { // draw a straight line ctx.beginPath(); - ctx.moveTo(this.from.x, this.from.y); - let via = this._getViaCoordinates(); - let returnValue = via; + ctx.moveTo(this.fromPoint.x, this.fromPoint.y); // fallback to normal straight edges - if (via.x === undefined) { - ctx.lineTo(this.to.x, this.to.y); - returnValue = undefined; + if (viaNode.x === undefined) { + ctx.lineTo(this.toPoint.x, this.toPoint.y); } else { - ctx.quadraticCurveTo(via.x, via.y, this.to.x, this.to.y); + ctx.quadraticCurveTo(viaNode.x, viaNode.y, this.toPoint.x, this.toPoint.y); } // draw shadow if enabled this.enableShadow(ctx); ctx.stroke(); this.disableShadow(ctx); - return returnValue; } + getViaNode() { + return this._getViaCoordinates(); + } + + + /** + * We do not use the to and fromPoints here to make the via nodes the same as edges without arrows. + * @returns {{x: undefined, y: undefined}} + * @private + */ _getViaCoordinates() { let xVia = undefined; let yVia = undefined; @@ -214,21 +220,21 @@ class BezierEdgeStatic extends BezierEdgeBase { return this._findBorderPositionBezier(nearNode, ctx, options.via); } - _getDistanceToEdge(x1, y1, x2, y2, x3, y3, via = this._getViaCoordinates()) { // x3,y3 is the point - return this._getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, via); + _getDistanceToEdge(x1, y1, x2, y2, x3, y3, viaNode = this._getViaCoordinates()) { // x3,y3 is the point + return this._getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, viaNode); } /** * Combined function of pointOnLine and pointOnBezier. This gives the coordinates of a point on the line at a certain percentage of the way * @param percentage - * @param via + * @param viaNode * @returns {{x: number, y: number}} * @private */ - getPoint(percentage, via = this._getViaCoordinates()) { + getPoint(percentage, viaNode = this._getViaCoordinates()) { var t = percentage; - var x = Math.pow(1 - t, 2) * this.from.x + (2 * t * (1 - t)) * via.x + Math.pow(t, 2) * this.to.x; - var y = Math.pow(1 - t, 2) * this.from.y + (2 * t * (1 - t)) * via.y + Math.pow(t, 2) * this.to.y; + var x = Math.pow(1 - t, 2) * this.fromPoint.x + (2 * t * (1 - t)) * viaNode.x + Math.pow(t, 2) * this.toPoint.x; + var y = Math.pow(1 - t, 2) * this.fromPoint.y + (2 * t * (1 - t)) * viaNode.y + Math.pow(t, 2) * this.toPoint.y; return {x: x, y: y}; } diff --git a/lib/network/modules/components/edges/CubicBezierEdge.js b/lib/network/modules/components/edges/CubicBezierEdge.js new file mode 100644 index 000000000..e7b766952 --- /dev/null +++ b/lib/network/modules/components/edges/CubicBezierEdge.js @@ -0,0 +1,93 @@ +import CubicBezierEdgeBase from './util/CubicBezierEdgeBase' + +class CubicBezierEdge extends CubicBezierEdgeBase { + constructor(options, body, labelModule) { + super(options, body, labelModule); + } + + /** + * Draw a line between two nodes + * @param {CanvasRenderingContext2D} ctx + * @private + */ + _line(ctx, viaNodes) { + // get the coordinates of the support points. + let via1 = viaNodes[0]; + let via2 = viaNodes[1]; + + // start drawing the line. + ctx.beginPath(); + ctx.moveTo(this.fromPoint.x, this.fromPoint.y); + + // fallback to normal straight edges + if (viaNodes === undefined || via1.x === undefined) { + ctx.lineTo(this.toPoint.x, this.toPoint.y); + } + else { + ctx.bezierCurveTo(via1.x, via1.y, via2.x, via2.y, this.toPoint.x, this.toPoint.y); + } + // draw shadow if enabled + this.enableShadow(ctx); + ctx.stroke(); + this.disableShadow(ctx); + } + + _getViaCoordinates() { + let dx = this.from.x - this.to.x; + let dy = this.from.y - this.to.y; + + let x1, y1, x2, y2; + let roundness = this.options.smooth.roundness; + + // horizontal if x > y or if direction is forced or if direction is horizontal + if ((Math.abs(dx) > Math.abs(dy) || this.options.smooth.forceDirection === true || this.options.smooth.forceDirection === 'horizontal') && this.options.smooth.forceDirection !== 'vertical') { + y1 = this.from.y; + y2 = this.to.y; + x1 = this.from.x - roundness * dx; + x2 = this.to.x + roundness * dx; + } + else { + y1 = this.from.y - roundness * dy; + y2 = this.to.y + roundness * dy; + x1 = this.from.x; + x2 = this.to.x; + } + + return [{x: x1, y: y1},{x: x2, y: y2}]; + } + + getViaNode() { + return this._getViaCoordinates(); + } + + _findBorderPosition(nearNode, ctx) { + return this._findBorderPositionBezier(nearNode, ctx); + } + + _getDistanceToEdge(x1, y1, x2, y2, x3, y3, [via1, via2] = this._getViaCoordinates()) { // x3,y3 is the point + return this._getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, via1, via2); + } + + /** + * Combined function of pointOnLine and pointOnBezier. This gives the coordinates of a point on the line at a certain percentage of the way + * @param percentage + * @param via + * @returns {{x: number, y: number}} + * @private + */ + getPoint(percentage, [via1, via2] = this._getViaCoordinates()) { + let t = percentage; + let vec = []; + vec[0] = Math.pow(1 - t, 3); + vec[1] = 3 * t * Math.pow(1 - t, 2); + vec[2] = 3 * Math.pow(t,2) * (1 - t); + vec[3] = Math.pow(t, 3); + let x = vec[0] * this.fromPoint.x + vec[1] * via1.x + vec[2] * via2.x + vec[3] * this.toPoint.x; + let y = vec[0] * this.fromPoint.y + vec[1] * via1.y + vec[2] * via2.y + vec[3] * this.toPoint.y; + + return {x: x, y: y}; + } +} + + +export default CubicBezierEdge; \ No newline at end of file diff --git a/lib/network/modules/components/edges/StraightEdge.js b/lib/network/modules/components/edges/StraightEdge.js index 0fc08a0fa..6d7bf231d 100644 --- a/lib/network/modules/components/edges/StraightEdge.js +++ b/lib/network/modules/components/edges/StraightEdge.js @@ -13,12 +13,15 @@ class StraightEdge extends EdgeBase { _line(ctx) { // draw a straight line ctx.beginPath(); - ctx.moveTo(this.from.x, this.from.y); - ctx.lineTo(this.to.x, this.to.y); + ctx.moveTo(this.fromPoint.x, this.fromPoint.y); + ctx.lineTo(this.toPoint.x, this.toPoint.y); // draw shadow if enabled this.enableShadow(ctx); ctx.stroke(); this.disableShadow(ctx); + } + + getViaNode() { return undefined; } @@ -31,8 +34,8 @@ class StraightEdge extends EdgeBase { */ getPoint(percentage) { return { - x: (1 - percentage) * this.from.x + percentage * this.to.x, - y: (1 - percentage) * this.from.y + percentage * this.to.y + x: (1 - percentage) * this.fromPoint.x + percentage * this.toPoint.x, + y: (1 - percentage) * this.fromPoint.y + percentage * this.toPoint.y } } diff --git a/lib/network/modules/components/edges/util/BezierEdgeBase.js b/lib/network/modules/components/edges/util/BezierEdgeBase.js index ea55651da..48b663bf6 100644 --- a/lib/network/modules/components/edges/util/BezierEdgeBase.js +++ b/lib/network/modules/components/edges/util/BezierEdgeBase.js @@ -73,18 +73,15 @@ class BezierEdgeBase extends EdgeBase { * Calculate the distance between a point (x3,y3) and a line segment from * (x1,y1) to (x2,y2). * http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment - * @param {number} x1 - * @param {number} y1 - * @param {number} x2 - * @param {number} y2 - * @param {number} x3 - * @param {number} y3 + * @param {number} x1 from x + * @param {number} y1 from y + * @param {number} x2 to x + * @param {number} y2 to y + * @param {number} x3 point to check x + * @param {number} y3 point to check y * @private */ _getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, via) { // x3,y3 is the point - let xVia, yVia; - xVia = via.x; - yVia = via.y; let minDistance = 1e9; let distance; let i, t, x, y; @@ -92,8 +89,8 @@ class BezierEdgeBase extends EdgeBase { let lastY = y1; for (i = 1; i < 10; i++) { t = 0.1 * i; - x = Math.pow(1 - t, 2) * x1 + (2 * t * (1 - t)) * xVia + Math.pow(t, 2) * x2; - y = Math.pow(1 - t, 2) * y1 + (2 * t * (1 - t)) * yVia + Math.pow(t, 2) * y2; + x = Math.pow(1 - t, 2) * x1 + (2 * t * (1 - t)) * via.x + Math.pow(t, 2) * x2; + y = Math.pow(1 - t, 2) * y1 + (2 * t * (1 - t)) * via.y + Math.pow(t, 2) * y2; if (i > 0) { distance = this._getDistanceToLine(lastX, lastY, x, y, x3, y3); minDistance = distance < minDistance ? distance : minDistance; diff --git a/lib/network/modules/components/edges/util/CubicBezierEdgeBase.js b/lib/network/modules/components/edges/util/CubicBezierEdgeBase.js new file mode 100644 index 000000000..a454eb48b --- /dev/null +++ b/lib/network/modules/components/edges/util/CubicBezierEdgeBase.js @@ -0,0 +1,48 @@ +import BezierEdgeBase from './BezierEdgeBase' + +class CubicBezierEdgeBase extends BezierEdgeBase { + constructor(options, body, labelModule) { + super(options, body, labelModule); + } + + /** + * Calculate the distance between a point (x3,y3) and a line segment from + * (x1,y1) to (x2,y2). + * http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment + * https://en.wikipedia.org/wiki/B%C3%A9zier_curve + * @param {number} x1 from x + * @param {number} y1 from y + * @param {number} x2 to x + * @param {number} y2 to y + * @param {number} x3 point to check x + * @param {number} y3 point to check y + * @private + */ + _getDistanceToBezierEdge(x1, y1, x2, y2, x3, y3, via1, via2) { // x3,y3 is the point + let minDistance = 1e9; + let distance; + let i, t, x, y; + let lastX = x1; + let lastY = y1; + let vec = [0,0,0,0] + for (i = 1; i < 10; i++) { + t = 0.1 * i; + vec[0] = Math.pow(1 - t, 3); + vec[1] = 3 * t * Math.pow(1 - t, 2); + vec[2] = 3 * Math.pow(t,2) * (1 - t); + vec[3] = Math.pow(t, 3); + x = vec[0] * x1 + vec[1] * via1.x + vec[2] * via2.x + vec[3] * x2; + y = vec[0] * y1 + vec[1] * via1.y + vec[2] * via2.y + vec[3] * y2; + if (i > 0) { + distance = this._getDistanceToLine(lastX, lastY, x, y, x3, y3); + minDistance = distance < minDistance ? distance : minDistance; + } + lastX = x; + lastY = y; + } + + return minDistance; + } +} + +export default CubicBezierEdgeBase; \ No newline at end of file diff --git a/lib/network/modules/components/edges/util/EdgeBase.js b/lib/network/modules/components/edges/util/EdgeBase.js index 447fef7df..659313a1c 100644 --- a/lib/network/modules/components/edges/util/EdgeBase.js +++ b/lib/network/modules/components/edges/util/EdgeBase.js @@ -4,11 +4,14 @@ class EdgeBase { constructor(options, body, labelModule) { this.body = body; this.labelModule = labelModule; + this.options = {}; this.setOptions(options); this.colorDirty = true; this.color = {}; this.selectionWidth = 2; this.hoverWidth = 1.5; + this.fromPoint = this.from; + this.toPoint = this.to; } connect() { @@ -24,12 +27,6 @@ class EdgeBase { this.id = this.options.id; } - /** - * overloadable if the shape has to toggle the via node to disabled - * @param status - */ - togglePhysics(status) {} - /** * Redraw a edge as a line * Draw this edge in the given canvas @@ -37,36 +34,32 @@ class EdgeBase { * @param {CanvasRenderingContext2D} ctx * @private */ - drawLine(ctx, selected, hover) { + drawLine(ctx, selected, hover, viaNode) { // set style ctx.strokeStyle = this.getColor(ctx, selected, hover); ctx.lineWidth = this.getLineWidth(selected, hover); - let via = undefined; + if (this.options.dashes !== false) { - via = this._drawDashedLine(ctx); + this._drawDashedLine(ctx, viaNode); } else { - via = this._drawLine(ctx); + this._drawLine(ctx, viaNode); } - return via; } - _drawLine(ctx) { - let via = undefined; + _drawLine(ctx, viaNode, fromPoint, toPoint) { if (this.from != this.to) { // draw line - via = this._line(ctx); + this._line(ctx, viaNode, fromPoint, toPoint); } else { let [x,y,radius] = this._getCircleData(ctx); this._circle(ctx, x, y, radius); } - return via; } - _drawDashedLine(ctx) { - let via = undefined; + _drawDashedLine(ctx, viaNode, fromPoint, toPoint) { ctx.lineCap = 'round'; let pattern = [5,5]; if (Array.isArray(this.options.dashes) === true) { @@ -84,7 +77,7 @@ class EdgeBase { // draw the line if (this.from != this.to) { // draw line - via = this._line(ctx); + this._line(ctx, viaNode); } else { let [x,y,radius] = this._getCircleData(ctx); @@ -97,8 +90,6 @@ class EdgeBase { ctx.restore(); } else { // unsupporting smooth lines - - if (this.from != this.to) { // draw line ctx.dashedLine(this.from.x, this.from.y, this.to.x, this.to.y, pattern); @@ -115,7 +106,6 @@ class EdgeBase { // disable shadows for other elements. this.disableShadow(ctx); } - return via; } @@ -407,26 +397,22 @@ class EdgeBase { return Math.sqrt(dx * dx + dy * dy); } + /** * * @param ctx * @param position * @param viaNode */ - drawArrowHead(ctx, position, viaNode, selected, hover) { - // set style - ctx.strokeStyle = this.getColor(ctx, selected, hover); - ctx.fillStyle = ctx.strokeStyle; - ctx.lineWidth = this.getLineWidth(selected, hover); - + getArrowData(ctx, position, viaNode, selected, hover) { // set lets let angle; - let length; - let arrowPos; + let arrowPoint; let node1; let node2; let guideOffset; let scaleFactor; + let lineWidth = this.getLineWidth(selected, hover); if (position === 'from') { node1 = this.from; @@ -451,67 +437,74 @@ class EdgeBase { if (position !== 'middle') { // draw arrow head if (this.options.smooth.enabled === true) { - arrowPos = this.findBorderPosition(node1, ctx, {via: viaNode}); - let guidePos = this.getPoint(Math.max(0.0, Math.min(1.0, arrowPos.t + guideOffset)), viaNode); - angle = Math.atan2((arrowPos.y - guidePos.y), (arrowPos.x - guidePos.x)); + arrowPoint = this.findBorderPosition(node1, ctx, {via: viaNode}); + let guidePos = this.getPoint(Math.max(0.0, Math.min(1.0, arrowPoint.t + guideOffset)), viaNode); + angle = Math.atan2((arrowPoint.y - guidePos.y), (arrowPoint.x - guidePos.x)); } else { angle = Math.atan2((node1.y - node2.y), (node1.x - node2.x)); - arrowPos = this.findBorderPosition(node1, ctx); + arrowPoint = this.findBorderPosition(node1, ctx); } } else { angle = Math.atan2((node1.y - node2.y), (node1.x - node2.x)); - arrowPos = this.getPoint(0.6, viaNode); // this is 0.6 to account for the size of the arrow. + arrowPoint = this.getPoint(0.5, viaNode); // this is 0.6 to account for the size of the arrow. } - // draw arrow at the end of the line - length = (10 + 5 * this.options.width) * scaleFactor; - ctx.arrow(arrowPos.x, arrowPos.y, angle, length); - - // draw shadow if enabled - this.enableShadow(ctx); - ctx.fill(); - - // disable shadows for other elements. - this.disableShadow(ctx); - ctx.stroke(); } else { // draw circle - let angle, point; let [x,y,radius] = this._getCircleData(ctx); if (position === 'from') { - point = this.findBorderPosition(this.from, ctx, {x, y, low:0.25, high:0.6, direction:-1}); - angle = point.t * -2 * Math.PI + 1.5 * Math.PI + 0.1 * Math.PI; + arrowPoint = this.findBorderPosition(this.from, ctx, {x, y, low:0.25, high:0.6, direction:-1}); + angle = arrowPoint.t * -2 * Math.PI + 1.5 * Math.PI + 0.1 * Math.PI; } else if (position === 'to') { - point = this.findBorderPosition(this.from, ctx, {x, y, low:0.6, high:1.0, direction:1}); - angle = point.t * -2 * Math.PI + 1.5 * Math.PI - 1.1 * Math.PI; + arrowPoint = this.findBorderPosition(this.from, ctx, {x, y, low:0.6, high:1.0, direction:1}); + angle = arrowPoint.t * -2 * Math.PI + 1.5 * Math.PI - 1.1 * Math.PI; } else { - point = this._pointOnCircle(x, y, radius, 0.175); + arrowPoint = this._pointOnCircle(x, y, radius, 0.175); angle = 3.9269908169872414; // === 0.175 * -2 * Math.PI + 1.5 * Math.PI + 0.1 * Math.PI; } - - // draw the arrowhead - let length = (10 + 5 * this.options.width) * scaleFactor; - ctx.arrow(point.x, point.y, angle, length); - - // draw shadow if enabled - this.enableShadow(ctx); - ctx.fill(); - - // disable shadows for other elements. - this.disableShadow(ctx); - ctx.stroke(); } + + let length = 15 * scaleFactor + 3 * lineWidth; // 3* lineWidth is the width of the edge. + + var xi = arrowPoint.x - length * 0.9 * Math.cos(angle); + var yi = arrowPoint.y - length * 0.9 * Math.sin(angle); + let arrowCore = {x: xi, y: yi}; + + return {point: arrowPoint, core: arrowCore, angle: angle, length: length}; + } + + /** + * + * @param ctx + * @param selected + * @param hover + * @param arrowData + */ + drawArrowHead(ctx, selected, hover, arrowData) { + // set style + ctx.strokeStyle = this.getColor(ctx, selected, hover); + ctx.fillStyle = ctx.strokeStyle; + ctx.lineWidth = this.getLineWidth(selected, hover); + + // draw arrow at the end of the line + ctx.arrow(arrowData.point.x, arrowData.point.y, arrowData.angle, arrowData.length); + + // draw shadow if enabled + this.enableShadow(ctx); + ctx.fill(); + // disable shadows for other elements. + this.disableShadow(ctx); } enableShadow(ctx) { if (this.options.shadow.enabled === true) { - ctx.shadowColor = 'rgba(0,0,0,0.5)'; + ctx.shadowColor = this.options.shadow.color; ctx.shadowBlur = this.options.shadow.size; ctx.shadowOffsetX = this.options.shadow.x; ctx.shadowOffsetY = this.options.shadow.y; diff --git a/lib/network/modules/components/nodes/shapes/Box.js b/lib/network/modules/components/nodes/shapes/Box.js index fe9810f31..d2d157408 100644 --- a/lib/network/modules/components/nodes/shapes/Box.js +++ b/lib/network/modules/components/nodes/shapes/Box.js @@ -32,43 +32,51 @@ class Box extends NodeBase { ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background; - let borderRadius = 6; + let borderRadius = this.options.shapeProperties.borderRadius; // only effective for box ctx.roundRect(this.left, this.top, this.width, this.height, borderRadius); - //draw dashed border if enabled - this.enableBorderDashes(ctx); // draw shadow if enabled this.enableShadow(ctx); + // draw the background ctx.fill(); - - //disable dashed border for other elements - this.disableBorderDashes(ctx); // disable shadows for other elements. this.disableShadow(ctx); - ctx.stroke(); + //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. + ctx.save(); + // if borders are zero width, they will be drawn with width 1 by default. This prevents that + if (borderWidth > 0) { + this.enableBorderDashes(ctx); + //draw the border + ctx.stroke(); + //disable dashed border for other elements + this.disableBorderDashes(ctx); + } + ctx.restore(); - this.updateBoundingBox(x,y); + this.updateBoundingBox(x,y,ctx,selected); this.labelModule.draw(ctx, x, y, selected); } - updateBoundingBox(x,y) { + updateBoundingBox(x,y, ctx, selected) { + this.resize(ctx, selected); this.left = x - this.width * 0.5; this.top = y - this.height * 0.5; - this.boundingBox.left = this.left; - this.boundingBox.top = this.top; - this.boundingBox.bottom = this.top + this.height; - this.boundingBox.right = this.left + this.width; + let borderRadius = this.options.shapeProperties.borderRadius; // only effective for box + this.boundingBox.left = this.left - borderRadius; + this.boundingBox.top = this.top - borderRadius; + this.boundingBox.bottom = this.top + this.height + borderRadius; + this.boundingBox.right = this.left + this.width + borderRadius; } distanceToBorder(ctx, angle) { this.resize(ctx); - let a = this.width / 2; - let b = this.height / 2; - let w = (Math.sin(angle) * a); - let h = (Math.cos(angle) * b); - return a * b / Math.sqrt(w * w + h * h); + let borderWidth = this.options.borderWidth; + + return Math.min( + Math.abs((this.width) / 2 / Math.cos(angle)), + Math.abs((this.height) / 2 / Math.sin(angle))) + borderWidth; } } diff --git a/lib/network/modules/components/nodes/shapes/Circle.js b/lib/network/modules/components/nodes/shapes/Circle.js index 3bfb828f5..221e92df3 100644 --- a/lib/network/modules/components/nodes/shapes/Circle.js +++ b/lib/network/modules/components/nodes/shapes/Circle.js @@ -45,11 +45,7 @@ class Circle extends CircleImageBase { distanceToBorder(ctx, angle) { this.resize(ctx); - var a = this.width / 2; - var b = this.height / 2; - var w = (Math.sin(angle) * a); - var h = (Math.cos(angle) * b); - return a * b / Math.sqrt(w * w + h * h); + return this.width * 0.5; } } diff --git a/lib/network/modules/components/nodes/shapes/CircularImage.js b/lib/network/modules/components/nodes/shapes/CircularImage.js index 82fb7c8f0..7d04b3f56 100644 --- a/lib/network/modules/components/nodes/shapes/CircularImage.js +++ b/lib/network/modules/components/nodes/shapes/CircularImage.js @@ -38,15 +38,16 @@ class CircularImage extends CircleImageBase { let size = Math.min(0.5*this.height, 0.5*this.width); + // draw the background circle. IMPORTANT: the stroke in this method is used by the clip method below. this._drawRawCircle(ctx, x, y, selected, hover, size); + // now we draw in the circle, we save so we can revert the clip operation after drawing. ctx.save(); - ctx.circle(x, y, size); - ctx.stroke(); + // clip is used to use the stroke in drawRawCircle as an area that we can draw in. ctx.clip(); - + // draw the image this._drawImageAtPosition(ctx); - + // restore so we can again draw on the full canvas ctx.restore(); this._drawImageLabel(ctx, x, y, selected); @@ -67,7 +68,7 @@ class CircularImage extends CircleImageBase { distanceToBorder(ctx, angle) { this.resize(ctx); - return this._distanceToBorder(angle); + return this.width * 0.5; } } diff --git a/lib/network/modules/components/nodes/shapes/Database.js b/lib/network/modules/components/nodes/shapes/Database.js index 11e3552c6..dabc02fff 100644 --- a/lib/network/modules/components/nodes/shapes/Database.js +++ b/lib/network/modules/components/nodes/shapes/Database.js @@ -23,32 +23,36 @@ class Database extends NodeBase { this.left = x - this.width / 2; this.top = y - this.height / 2; - var borderWidth = this.options.borderWidth; + var neutralborderWidth = this.options.borderWidth; var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; + var borderWidth = (selected ? selectionLineWidth : neutralborderWidth) / this.body.view.scale; + ctx.lineWidth = Math.min(this.width, borderWidth); ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border; - ctx.lineWidth = (this.selected ? selectionLineWidth : borderWidth); - ctx.lineWidth *= this.networkScaleInv; - ctx.lineWidth = Math.min(this.width, ctx.lineWidth); ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background; ctx.database(x - this.width / 2, y - this.height * 0.5, this.width, this.height); - //draw dashed border if enabled - this.enableBorderDashes(ctx); // draw shadow if enabled this.enableShadow(ctx); + // draw the background ctx.fill(); - - //disable dashed border for other elements - this.disableBorderDashes(ctx); // disable shadows for other elements. this.disableShadow(ctx); - ctx.stroke(); + //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. + ctx.save(); + // if borders are zero width, they will be drawn with width 1 by default. This prevents that + if (borderWidth > 0) { + this.enableBorderDashes(ctx); + //draw the border + ctx.stroke(); + //disable dashed border for other elements + this.disableBorderDashes(ctx); + } + ctx.restore(); this.updateBoundingBox(x,y,ctx,selected); - this.labelModule.draw(ctx, x, y, selected); } @@ -65,12 +69,7 @@ class Database extends NodeBase { } distanceToBorder(ctx, angle) { - this.resize(ctx); - var a = this.width / 2; - var b = this.height / 2; - var w = (Math.sin(angle) * a); - var h = (Math.cos(angle) * b); - return a * b / Math.sqrt(w * w + h * h); + return this._distanceToBorder(ctx,angle); } } diff --git a/lib/network/modules/components/nodes/shapes/Diamond.js b/lib/network/modules/components/nodes/shapes/Diamond.js index 15d348bf4..9a72caaa0 100644 --- a/lib/network/modules/components/nodes/shapes/Diamond.js +++ b/lib/network/modules/components/nodes/shapes/Diamond.js @@ -16,7 +16,7 @@ class Diamond extends ShapeBase { } distanceToBorder(ctx, angle) { - return this._distanceToBorder(angle); + return this._distanceToBorder(ctx,angle); } } diff --git a/lib/network/modules/components/nodes/shapes/Dot.js b/lib/network/modules/components/nodes/shapes/Dot.js index 28fb920cb..684091393 100644 --- a/lib/network/modules/components/nodes/shapes/Dot.js +++ b/lib/network/modules/components/nodes/shapes/Dot.js @@ -16,7 +16,8 @@ class Dot extends ShapeBase { } distanceToBorder(ctx, angle) { - return this.options.size + this.options.borderWidth; + this.resize(ctx); + return this.options.size; } } diff --git a/lib/network/modules/components/nodes/shapes/Ellipse.js b/lib/network/modules/components/nodes/shapes/Ellipse.js index 2d486e6ea..b4d0a28a6 100644 --- a/lib/network/modules/components/nodes/shapes/Ellipse.js +++ b/lib/network/modules/components/nodes/shapes/Ellipse.js @@ -25,30 +25,36 @@ class Ellipse extends NodeBase { this.left = x - this.width * 0.5; this.top = y - this.height * 0.5; - var borderWidth = this.options.borderWidth; + var neutralborderWidth = this.options.borderWidth; var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; + var borderWidth = (selected ? selectionLineWidth : neutralborderWidth) / this.body.view.scale; + ctx.lineWidth = Math.min(this.width, borderWidth); ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border; - ctx.lineWidth = (selected ? selectionLineWidth : borderWidth); - ctx.lineWidth /= this.body.view.scale; - ctx.lineWidth = Math.min(this.width, ctx.lineWidth); - ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background; ctx.ellipse(this.left, this.top, this.width, this.height); - //draw dashed border if enabled - this.enableBorderDashes(ctx); // draw shadow if enabled this.enableShadow(ctx); + // draw the background ctx.fill(); - - //disable dashed border for other elements - this.disableBorderDashes(ctx); // disable shadows for other elements. this.disableShadow(ctx); - ctx.stroke(); + //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. + ctx.save(); + + // if borders are zero width, they will be drawn with width 1 by default. This prevents that + if (borderWidth > 0) { + this.enableBorderDashes(ctx); + //draw the border + ctx.stroke(); + //disable dashed border for other elements + this.disableBorderDashes(ctx); + } + + ctx.restore(); this.updateBoundingBox(x, y, ctx, selected); this.labelModule.draw(ctx, x, y, selected); diff --git a/lib/network/modules/components/nodes/shapes/Icon.js b/lib/network/modules/components/nodes/shapes/Icon.js index 193044f80..c01df0527 100644 --- a/lib/network/modules/components/nodes/shapes/Icon.js +++ b/lib/network/modules/components/nodes/shapes/Icon.js @@ -61,7 +61,6 @@ class Icon extends NodeBase { ctx.textAlign = "center"; ctx.textBaseline = "middle"; - // draw shadow if enabled this.enableShadow(ctx); ctx.fillText(this.options.icon.code, x, y); @@ -76,8 +75,7 @@ class Icon extends NodeBase { } distanceToBorder(ctx, angle) { - this.resize(ctx); - return this._distanceToBorder(angle); + return this._distanceToBorder(ctx,angle); } } diff --git a/lib/network/modules/components/nodes/shapes/Image.js b/lib/network/modules/components/nodes/shapes/Image.js index 78aef4b4e..1a0e8aecf 100644 --- a/lib/network/modules/components/nodes/shapes/Image.js +++ b/lib/network/modules/components/nodes/shapes/Image.js @@ -17,6 +17,42 @@ class Image extends CircleImageBase { this.left = x - this.width / 2; this.top = y - this.height / 2; + if (this.options.shapeProperties.useBorderWithImage === true) { + var neutralborderWidth = this.options.borderWidth; + var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; + var borderWidth = (selected ? selectionLineWidth : neutralborderWidth) / this.body.view.scale; + ctx.lineWidth = Math.min(this.width, borderWidth); + + ctx.beginPath(); + + // setup the line properties. + ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border; + + // set a fillstyle + ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background; + + // draw a rectangle to form the border around. This rectangle is filled so the opacity of a picture (in future vis releases?) can be used to tint the image + ctx.rect(this.left - 0.5 * ctx.lineWidth, + this.top - 0.5 * ctx.lineWidth, + this.width + ctx.lineWidth, + this.height + ctx.lineWidth); + ctx.fill(); + + //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. + ctx.save(); + // if borders are zero width, they will be drawn with width 1 by default. This prevents that + if (borderWidth > 0) { + this.enableBorderDashes(ctx); + //draw the border + ctx.stroke(); + //disable dashed border for other elements + this.disableBorderDashes(ctx); + } + ctx.restore(); + + ctx.closePath(); + } + this._drawImageAtPosition(ctx); this._drawImageLabel(ctx, x, y, selected || hover); @@ -42,12 +78,7 @@ class Image extends CircleImageBase { } distanceToBorder(ctx, angle) { - this.resize(ctx); - var a = this.width / 2; - var b = this.height / 2; - var w = (Math.sin(angle) * a); - var h = (Math.cos(angle) * b); - return a * b / Math.sqrt(w * w + h * h); + return this._distanceToBorder(ctx,angle); } } diff --git a/lib/network/modules/components/nodes/shapes/Square.js b/lib/network/modules/components/nodes/shapes/Square.js index 80024615d..e2fc6ebba 100644 --- a/lib/network/modules/components/nodes/shapes/Square.js +++ b/lib/network/modules/components/nodes/shapes/Square.js @@ -16,8 +16,7 @@ class Square extends ShapeBase { } distanceToBorder(ctx, angle) { - this.resize(); - return this._distanceToBorder(angle); + return this._distanceToBorder(ctx,angle); } } diff --git a/lib/network/modules/components/nodes/shapes/Star.js b/lib/network/modules/components/nodes/shapes/Star.js index df23b4587..4aae4fae1 100644 --- a/lib/network/modules/components/nodes/shapes/Star.js +++ b/lib/network/modules/components/nodes/shapes/Star.js @@ -16,7 +16,7 @@ class Star extends ShapeBase { } distanceToBorder(ctx, angle) { - return this._distanceToBorder(angle); + return this._distanceToBorder(ctx,angle); } } diff --git a/lib/network/modules/components/nodes/shapes/Text.js b/lib/network/modules/components/nodes/shapes/Text.js index 4f0fb81a9..ac22dc5ce 100644 --- a/lib/network/modules/components/nodes/shapes/Text.js +++ b/lib/network/modules/components/nodes/shapes/Text.js @@ -45,8 +45,7 @@ class Text extends NodeBase { } distanceToBorder(ctx, angle) { - this.resize(ctx); - return this._distanceToBorder(angle); + return this._distanceToBorder(ctx,angle); } } diff --git a/lib/network/modules/components/nodes/shapes/Triangle.js b/lib/network/modules/components/nodes/shapes/Triangle.js index c8aac1d7f..6c58a4f12 100644 --- a/lib/network/modules/components/nodes/shapes/Triangle.js +++ b/lib/network/modules/components/nodes/shapes/Triangle.js @@ -16,7 +16,7 @@ class Triangle extends ShapeBase { } distanceToBorder(ctx, angle) { - return this._distanceToBorder(angle); + return this._distanceToBorder(ctx,angle); } } diff --git a/lib/network/modules/components/nodes/shapes/TriangleDown.js b/lib/network/modules/components/nodes/shapes/TriangleDown.js index 6b64f5956..044fd7c51 100644 --- a/lib/network/modules/components/nodes/shapes/TriangleDown.js +++ b/lib/network/modules/components/nodes/shapes/TriangleDown.js @@ -16,7 +16,7 @@ class TriangleDown extends ShapeBase { } distanceToBorder(ctx, angle) { - return this._distanceToBorder(angle); + return this._distanceToBorder(ctx,angle); } } diff --git a/lib/network/modules/components/nodes/util/CircleImageBase.js b/lib/network/modules/components/nodes/util/CircleImageBase.js index 4d941388a..ab0cf97f3 100644 --- a/lib/network/modules/components/nodes/util/CircleImageBase.js +++ b/lib/network/modules/components/nodes/util/CircleImageBase.js @@ -7,6 +7,13 @@ class CircleImageBase extends NodeBase { this.imageLoaded = false; } + setOptions(options, imageObj) { + this.options = options; + if (imageObj) { + this.imageObj = imageObj; + } + } + /** * This function resizes the image by the options size when the image has not yet loaded. If the image has loaded, we * force the update of the size again. @@ -29,20 +36,27 @@ class CircleImageBase extends NodeBase { width = 0; height = 0; } - if (this.imageObj.width > this.imageObj.height) { - ratio = this.imageObj.width / this.imageObj.height; - width = this.options.size * 2 * ratio || this.imageObj.width; - height = this.options.size * 2 || this.imageObj.height; - } - else { - if (this.imageObj.width && this.imageObj.height) { // not undefined or 0 - ratio = this.imageObj.height / this.imageObj.width; + if (this.options.shapeProperties.useImageSize === false) { + if (this.imageObj.width > this.imageObj.height) { + ratio = this.imageObj.width / this.imageObj.height; + width = this.options.size * 2 * ratio || this.imageObj.width; + height = this.options.size * 2 || this.imageObj.height; } else { - ratio = 1; + if (this.imageObj.width && this.imageObj.height) { // not undefined or 0 + ratio = this.imageObj.height / this.imageObj.width; + } + else { + ratio = 1; + } + width = this.options.size * 2; + height = this.options.size * 2 * ratio; } - width = this.options.size * 2 || this.imageObj.width; - height = this.options.size * 2 * ratio || this.imageObj.height; + } + else { + // when not using the size property, we use the image size + width = this.imageObj.width; + height = this.imageObj.height; } this.width = width; this.height = height; @@ -52,29 +66,33 @@ class CircleImageBase extends NodeBase { } _drawRawCircle(ctx, x, y, selected, hover, size) { - var borderWidth = this.options.borderWidth; + var neutralborderWidth = this.options.borderWidth; var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; + var borderWidth = (selected ? selectionLineWidth : neutralborderWidth) / this.body.view.scale; + ctx.lineWidth = Math.min(this.width, borderWidth); ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border; - - ctx.lineWidth = (selected ? selectionLineWidth : borderWidth); - ctx.lineWidth *= this.networkScaleInv; - ctx.lineWidth = Math.min(this.width, ctx.lineWidth); ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background; ctx.circle(x, y, size); - //draw dashed border if enabled - this.enableBorderDashes(ctx); // draw shadow if enabled this.enableShadow(ctx); + // draw the background ctx.fill(); - - //disable dashed border for other elements - this.disableBorderDashes(ctx); // disable shadows for other elements. this.disableShadow(ctx); - ctx.stroke(); + //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. + ctx.save(); + // if borders are zero width, they will be drawn with width 1 by default. This prevents that + if (borderWidth > 0) { + this.enableBorderDashes(ctx); + //draw the border + ctx.stroke(); + //disable dashed border for other elements + this.disableBorderDashes(ctx); + } + ctx.restore(); } _drawImageAtPosition(ctx) { @@ -84,6 +102,8 @@ class CircleImageBase extends NodeBase { // draw shadow if enabled this.enableShadow(ctx); + + // draw image ctx.drawImage(this.imageObj, this.left, this.top, this.width, this.height); // disable shadows for other elements. diff --git a/lib/network/modules/components/nodes/util/NodeBase.js b/lib/network/modules/components/nodes/util/NodeBase.js index 78a2d9e47..1cf6bf533 100644 --- a/lib/network/modules/components/nodes/util/NodeBase.js +++ b/lib/network/modules/components/nodes/util/NodeBase.js @@ -15,8 +15,9 @@ class NodeBase { this.options = options; } - _distanceToBorder(angle) { - var borderWidth = 1; + _distanceToBorder(ctx,angle) { + var borderWidth = this.options.borderWidth; + this.resize(ctx); return Math.min( Math.abs(this.width / 2 / Math.cos(angle)), Math.abs(this.height / 2 / Math.sin(angle))) + borderWidth; @@ -24,7 +25,7 @@ class NodeBase { enableShadow(ctx) { if (this.options.shadow.enabled === true) { - ctx.shadowColor = 'rgba(0,0,0,0.5)'; + ctx.shadowColor = this.options.shadow.color; ctx.shadowBlur = this.options.shadow.size; ctx.shadowOffsetX = this.options.shadow.x; ctx.shadowOffsetY = this.options.shadow.y; @@ -42,13 +43,29 @@ class NodeBase { enableBorderDashes(ctx) { if (this.options.shapeProperties.borderDashes !== false) { - ctx.setLineDash(this.options.shapeProperties.borderDashes); + if (ctx.setLineDash !== undefined) { + let dashes = this.options.shapeProperties.borderDashes; + if (dashes === true) { + dashes = [5,15] + } + ctx.setLineDash(dashes); + } + else { + console.warn("setLineDash is not supported in this browser. The dashed borders cannot be used."); + this.options.shapeProperties.borderDashes = false; + } } } disableBorderDashes(ctx) { - if (this.options.shapeProperties.borderDashes == false) { - ctx.setLineDash([0]); + if (this.options.shapeProperties.borderDashes !== false) { + if (ctx.setLineDash !== undefined) { + ctx.setLineDash([0]); + } + else { + console.warn("setLineDash is not supported in this browser. The dashed borders cannot be used."); + this.options.shapeProperties.borderDashes = false; + } } } } diff --git a/lib/network/modules/components/nodes/util/ShapeBase.js b/lib/network/modules/components/nodes/util/ShapeBase.js index dc99032c3..d9e2cce24 100644 --- a/lib/network/modules/components/nodes/util/ShapeBase.js +++ b/lib/network/modules/components/nodes/util/ShapeBase.js @@ -20,29 +20,33 @@ class ShapeBase extends NodeBase { this.left = x - this.width / 2; this.top = y - this.height / 2; - var borderWidth = this.options.borderWidth; + var neutralborderWidth = this.options.borderWidth; var selectionLineWidth = this.options.borderWidthSelected || 2 * this.options.borderWidth; + var borderWidth = (selected ? selectionLineWidth : neutralborderWidth) / this.body.view.scale; + ctx.lineWidth = Math.min(this.width, borderWidth); ctx.strokeStyle = selected ? this.options.color.highlight.border : hover ? this.options.color.hover.border : this.options.color.border; - ctx.lineWidth = (selected ? selectionLineWidth : borderWidth); - ctx.lineWidth /= this.body.view.scale; - ctx.lineWidth = Math.min(this.width, ctx.lineWidth); ctx.fillStyle = selected ? this.options.color.highlight.background : hover ? this.options.color.hover.background : this.options.color.background; ctx[shape](x, y, this.options.size); - //draw dashed border if enabled - this.enableBorderDashes(ctx); // draw shadow if enabled this.enableShadow(ctx); + // draw the background ctx.fill(); - - //disable dashed border for other elements - this.disableBorderDashes(ctx); // disable shadows for other elements. this.disableShadow(ctx); - ctx.stroke(); - + //draw dashed border if enabled, save and restore is required for firefox not to crash on unix. + ctx.save(); + // if borders are zero width, they will be drawn with width 1 by default. This prevents that + if (borderWidth > 0) { + this.enableBorderDashes(ctx); + //draw the border + ctx.stroke(); + //disable dashed border for other elements + this.disableBorderDashes(ctx); + } + ctx.restore(); if (this.options.label !== undefined) { let yLabel = y + 0.5 * this.height + 3; // the + 3 is to offset it a bit below the node. diff --git a/lib/network/modules/components/physics/BarnesHutSolver.js b/lib/network/modules/components/physics/BarnesHutSolver.js index b2da2d60f..2c514e207 100644 --- a/lib/network/modules/components/physics/BarnesHutSolver.js +++ b/lib/network/modules/components/physics/BarnesHutSolver.js @@ -6,6 +6,9 @@ class BarnesHutSolver { this.barnesHutTree; this.setOptions(options); this.randomSeed = 5; + + // debug: show grid + //this.body.emitter.on("afterDrawing", (ctx) => {this._debug(ctx,'#ff0000')}) } setOptions(options) { @@ -21,7 +24,7 @@ class BarnesHutSolver { /** - * This function calculates the forces the nodes apply on eachother based on a gravitational model. + * This function calculates the forces the nodes apply on each other based on a gravitational model. * The Barnes Hut method is used to speed up this N-body simulation. * * @private @@ -285,7 +288,7 @@ class BarnesHutSolver { break; case 1: // convert into children // if there are two nodes exactly overlapping (on init, on opening of cluster etc.) - // we move one node a pixel and we do not put it in the tree. + // we move one node a little bit and we do not put it in the tree. if (parentBranch.children[region].children.data.x === node.x && parentBranch.children[region].children.data.y === node.y) { node.x += this.seededRandom(); diff --git a/lib/network/modules/components/shared/Label.js b/lib/network/modules/components/shared/Label.js index 0141bc363..64c0c3748 100644 --- a/lib/network/modules/components/shared/Label.js +++ b/lib/network/modules/components/shared/Label.js @@ -6,21 +6,26 @@ class Label { this.pointToSelf = false; this.baseSize = undefined; + this.fontOptions = {}; this.setOptions(options); this.size = {top: 0, left: 0, width: 0, height: 0, yLine: 0}; // could be cached } setOptions(options, allowDeletion = false) { - this.options = options; + this.nodeOptions = options; + + // We want to keep the font options seperated from the node options. + // The node options have to mirror the globals when they are not overruled. + this.fontOptions = util.deepExtend({},options.font, true); if (options.label !== undefined) { this.labelDirty = true; } if (options.font !== undefined) { - Label.parseOptions(this.options.font, options, allowDeletion); + Label.parseOptions(this.fontOptions, options, allowDeletion); if (typeof options.font === 'string') { - this.baseSize = this.options.font.size; + this.baseSize = this.fontOptions.size; } else if (typeof options.font === 'object') { if (options.font.size !== undefined) { @@ -54,12 +59,12 @@ class Label { */ draw(ctx, x, y, selected, baseline = 'middle') { // if no label, return - if (this.options.label === undefined) + if (this.nodeOptions.label === undefined) return; // check if we have to render the label - let viewFontSize = this.options.font.size * this.body.view.scale; - if (this.options.label && viewFontSize < this.options.scaling.label.drawThreshold - 1) + let viewFontSize = this.fontOptions.size * this.body.view.scale; + if (this.nodeOptions.label && viewFontSize < this.nodeOptions.scaling.label.drawThreshold - 1) return; // update the size cache if required @@ -77,12 +82,12 @@ class Label { * @private */ _drawBackground(ctx) { - if (this.options.font.background !== undefined && this.options.font.background !== "none") { - ctx.fillStyle = this.options.font.background; + if (this.fontOptions.background !== undefined && this.fontOptions.background !== "none") { + ctx.fillStyle = this.fontOptions.background; let lineMargin = 2; - switch (this.options.font.align) { + switch (this.fontOptions.align) { case 'middle': ctx.fillRect(-this.size.width * 0.5, -this.size.height * 0.5, this.size.width, this.size.height); break; @@ -108,11 +113,11 @@ class Label { * @private */ _drawText(ctx, selected, x, y, baseline = 'middle') { - let fontSize = this.options.font.size; + let fontSize = this.fontOptions.size; let viewFontSize = fontSize * this.body.view.scale; // this ensures that there will not be HUGE letters on screen by setting an upper limit on the visible text size (regardless of zoomLevel) - if (viewFontSize >= this.options.scaling.label.maxVisible) { - fontSize = Number(this.options.scaling.label.maxVisible) / this.body.view.scale; + if (viewFontSize >= this.nodeOptions.scaling.label.maxVisible) { + fontSize = Number(this.nodeOptions.scaling.label.maxVisible) / this.body.view.scale; } let yLine = this.size.yLine; @@ -120,20 +125,20 @@ class Label { [x, yLine] = this._setAlignment(ctx, x, yLine, baseline); // configure context for drawing the text - ctx.font = (selected && this.options.labelHighlightBold ? 'bold ' : '') + fontSize + "px " + this.options.font.face; + ctx.font = (selected && this.nodeOptions.labelHighlightBold ? 'bold ' : '') + fontSize + "px " + this.fontOptions.face; ctx.fillStyle = fontColor; ctx.textAlign = 'center'; // set the strokeWidth - if (this.options.font.strokeWidth > 0) { - ctx.lineWidth = this.options.font.strokeWidth; + if (this.fontOptions.strokeWidth > 0) { + ctx.lineWidth = this.fontOptions.strokeWidth; ctx.strokeStyle = strokeColor; ctx.lineJoin = 'round'; } // draw the text for (let i = 0; i < this.lineCount; i++) { - if (this.options.font.strokeWidth > 0) { + if (this.fontOptions.strokeWidth > 0) { ctx.strokeText(this.lines[i], x, yLine); } ctx.fillText(this.lines[i], x, yLine); @@ -144,16 +149,16 @@ class Label { _setAlignment(ctx, x, yLine, baseline) { // check for label alignment (for edges) // TODO: make alignment for nodes - if (this.options.font.align !== 'horizontal' && this.pointToSelf === false) { + if (this.fontOptions.align !== 'horizontal' && this.pointToSelf === false) { x = 0; yLine = 0; let lineMargin = 2; - if (this.options.font.align === 'top') { + if (this.fontOptions.align === 'top') { ctx.textBaseline = 'alphabetic'; yLine -= 2 * lineMargin; // distance from edge, required because we use alphabetic. Alphabetic has less difference between browsers } - else if (this.options.font.align === 'bottom') { + else if (this.fontOptions.align === 'bottom') { ctx.textBaseline = 'hanging'; yLine += 2 * lineMargin;// distance from edge, required because we use hanging. Hanging has less difference between browsers } @@ -177,10 +182,10 @@ class Label { * @private */ _getColor(viewFontSize) { - let fontColor = this.options.font.color || '#000000'; - let strokeColor = this.options.font.strokeColor || '#ffffff'; - if (viewFontSize <= this.options.scaling.label.drawThreshold) { - let opacity = Math.max(0, Math.min(1, 1 - (this.options.scaling.label.drawThreshold - viewFontSize))); + let fontColor = this.fontOptions.color || '#000000'; + let strokeColor = this.fontOptions.strokeColor || '#ffffff'; + if (viewFontSize <= this.nodeOptions.scaling.label.drawThreshold) { + let opacity = Math.max(0, Math.min(1, 1 - (this.nodeOptions.scaling.label.drawThreshold - viewFontSize))); fontColor = util.overrideOpacity(fontColor, opacity); strokeColor = util.overrideOpacity(strokeColor, opacity); } @@ -197,7 +202,7 @@ class Label { getTextSize(ctx, selected = false) { let size = { width: this._processLabel(ctx,selected), - height: this.options.font.size * this.lineCount, + height: this.fontOptions.size * this.lineCount, lineCount: this.lineCount }; return size; @@ -216,12 +221,12 @@ class Label { if (this.labelDirty === true) { this.size.width = this._processLabel(ctx,selected); } - this.size.height = this.options.font.size * this.lineCount; + this.size.height = this.fontOptions.size * this.lineCount; this.size.left = x - this.size.width * 0.5; this.size.top = y - this.size.height * 0.5; - this.size.yLine = y + (1 - this.lineCount) * 0.5 * this.options.font.size; + this.size.yLine = y + (1 - this.lineCount) * 0.5 * this.fontOptions.size; if (baseline === "hanging") { - this.size.top += 0.5 * this.options.font.size; + this.size.top += 0.5 * this.fontOptions.size; this.size.top += 4; // distance from node, required because we use hanging. Hanging has less difference between browsers this.size.yLine += 4; // distance from node } @@ -241,10 +246,10 @@ class Label { let width = 0; let lines = ['']; let lineCount = 0; - if (this.options.label !== undefined) { - lines = String(this.options.label).split('\n'); + if (this.nodeOptions.label !== undefined) { + lines = String(this.nodeOptions.label).split('\n'); lineCount = lines.length; - ctx.font = (selected && this.options.labelHighlightBold ? 'bold ' : '') + this.options.font.size + "px " + this.options.font.face; + ctx.font = (selected && this.nodeOptions.labelHighlightBold ? 'bold ' : '') + this.fontOptions.size + "px " + this.fontOptions.face; width = ctx.measureText(lines[0]).width; for (let i = 1; i < lineCount; i++) { let lineWidth = ctx.measureText(lines[i]).width; diff --git a/lib/network/options.js b/lib/network/options.js index 3421f4926..599041630 100644 --- a/lib/network/options.js +++ b/lib/network/options.js @@ -29,6 +29,7 @@ let allOptions = { from: { enabled: { boolean }, scaleFactor: { number }, __type__: { object, boolean } }, __type__: { string: ['from', 'to', 'middle'], object } }, + arrowStrikethrough: { boolean }, color: { color: { string }, highlight: { string }, @@ -72,6 +73,7 @@ let allOptions = { selfReferenceSize: { number }, shadow: { enabled: { boolean }, + color: { string }, size: { number }, x: { number }, y: { number }, @@ -79,8 +81,9 @@ let allOptions = { }, smooth: { enabled: { boolean }, - type: { string: ['dynamic', 'continuous', 'discrete', 'diagonalCross', 'straightCross', 'horizontal', 'vertical', 'curvedCW', 'curvedCCW'] }, + type: { string: ['dynamic', 'continuous', 'discrete', 'diagonalCross', 'straightCross', 'horizontal', 'vertical', 'curvedCW', 'curvedCCW', 'cubicBezier'] }, roundness: { number }, + forceDirection: { string: ['horizontal', 'vertical', 'none'], boolean }, __type__: { object, boolean } }, title: { string, 'undefined': 'undefined' }, @@ -116,9 +119,14 @@ let allOptions = { }, layout: { randomSeed: { 'undefined': 'undefined', number }, + improvedLayout: { boolean }, hierarchical: { enabled: { boolean }, levelSeparation: { number }, + nodeSpacing: { number }, + treeSpacing: { number }, + blockShifting: { boolean }, + edgeMinimization: { boolean }, direction: { string: ['UD', 'DU', 'LR', 'RL'] }, // UD, DU, LR, RL sortMethod: { string: ['hubsize', 'directed'] }, // hubsize, directed __type__: { object, boolean } @@ -202,6 +210,7 @@ let allOptions = { }, shadow: { enabled: { boolean }, + color: { string }, size: { number }, x: { number }, y: { number }, @@ -210,6 +219,9 @@ let allOptions = { shape: { string: ['ellipse', 'circle', 'database', 'box', 'text', 'image', 'circularImage', 'diamond', 'dot', 'star', 'triangle', 'triangleDown', 'square', 'icon'] }, shapeProperties: { borderDashes: { boolean, array }, + borderRadius: { number }, + useImageSize: { boolean }, + useBorderWithImage: { boolean }, __type__: { object } }, size: { number }, @@ -267,6 +279,7 @@ let allOptions = { __type__: { object, boolean } }, timestep: { number }, + adaptiveTimestep: { boolean }, __type__: { object, boolean } }, @@ -339,13 +352,16 @@ let configureOptions = { }, shadow: { enabled: false, + color: 'rgba(0,0,0,0.5)', size: [10, 0, 20, 1], x: [5, -30, 30, 1], y: [5, -30, 30, 1] }, shape: ['ellipse', 'box', 'circle', 'database', 'diamond', 'dot', 'square', 'star', 'text', 'triangle', 'triangleDown'], shapeProperties: { - borderDashes: false + borderDashes: false, + borderRadius: [6, 0, 20, 1], + useImageSize: false }, size: [25, 0, 200, 1] }, @@ -355,6 +371,7 @@ let configureOptions = { middle: { enabled: false, scaleFactor: [1, 0, 3, 0.05] }, from: { enabled: false, scaleFactor: [1, 0, 3, 0.05] } }, + arrowStrikethrough: true, color: { color: ['color', '#848484'], highlight: ['color', '#848484'], @@ -391,22 +408,29 @@ let configureOptions = { selfReferenceSize: [20, 0, 200, 1], shadow: { enabled: false, + color: 'rgba(0,0,0,0.5)', size: [10, 0, 20, 1], x: [5, -30, 30, 1], y: [5, -30, 30, 1] }, smooth: { enabled: true, - type: ['dynamic', 'continuous', 'discrete', 'diagonalCross', 'straightCross', 'horizontal', 'vertical', 'curvedCW', 'curvedCCW'], + type: ['dynamic', 'continuous', 'discrete', 'diagonalCross', 'straightCross', 'horizontal', 'vertical', 'curvedCW', 'curvedCCW', 'cubicBezier'], + forceDirection: ['horizontal', 'vertical', 'none'], roundness: [0.5, 0, 1, 0.05] }, width: [1, 0, 30, 1] }, layout: { //randomSeed: [0, 0, 500, 1], + //improvedLayout: true, hierarchical: { enabled: false, levelSeparation: [150, 20, 500, 5], + nodeSpacing: [100, 20, 500, 5], + treeSpacing: [200, 20, 500, 5], + blockShifting: true, + edgeMinimization: true, direction: ['UD', 'DU', 'LR', 'RL'], // UD, DU, LR, RL sortMethod: ['hubsize', 'directed'] // hubsize, directed } @@ -471,7 +495,8 @@ let configureOptions = { maxVelocity: [50, 0, 150, 1], minVelocity: [0.1, 0.01, 0.5, 0.01], solver: ['barnesHut', 'forceAtlas2Based', 'repulsion', 'hierarchicalRepulsion'], - timestep: [0.5, 0.01, 1, 0.01] + timestep: [0.5, 0.01, 1, 0.01], + //adaptiveTimestep: true }, global: { locale: ['en', 'nl'] diff --git a/lib/shared/ColorPicker.js b/lib/shared/ColorPicker.js index a0d81b122..adbfe10be 100644 --- a/lib/shared/ColorPicker.js +++ b/lib/shared/ColorPicker.js @@ -16,6 +16,7 @@ class ColorPicker { // bound by this.updateCallback = () => {}; + this.closeCallback = () => {}; // create all DOM elements this._create(); @@ -42,12 +43,25 @@ class ColorPicker { * the callback is executed on apply and save. Bind it to the application * @param callback */ - setCallback(callback) { + setUpdateCallback(callback) { if (typeof callback === 'function') { this.updateCallback = callback; } else { - throw new Error("Function attempted to set as colorPicker callback is not a function."); + throw new Error("Function attempted to set as colorPicker update callback is not a function."); + } + } + + /** + * the callback is executed on apply and save. Bind it to the application + * @param callback + */ + setCloseCallback(callback) { + if (typeof callback === 'function') { + this.closeCallback = callback; + } + else { + throw new Error("Function attempted to set as colorPicker closing callback is not a function."); } } @@ -119,19 +133,20 @@ class ColorPicker { /** - * this shows the color picker at a location. The hue circle is constructed once and stored. - * @param x - * @param y + * this shows the color picker. + * The hue circle is constructed once and stored. */ - show(x,y) { + show() { + if (this.closeCallback !== undefined) { + this.closeCallback(); + this.closeCallback = undefined; + } + this.applied = false; this.frame.style.display = 'block'; - this.frame.style.top = y + 'px'; - this.frame.style.left = x + 'px'; this._generateHueCircle(); } - // ------------------------------------------ PRIVATE ----------------------------- // /** @@ -151,6 +166,15 @@ class ColorPicker { } this.frame.style.display = 'none'; + + // call the closing callback, restoring the onclick method. + // this is in a setTimeout because it will trigger the show again before the click is done. + setTimeout(() => { + if (this.closeCallback !== undefined) { + this.closeCallback(); + this.closeCallback = undefined; + } + },0); } @@ -244,7 +268,7 @@ class ColorPicker { /** - * update the colorpicker. A black circle overlays the hue circle to mimic the brightness decreasing. + * update the color picker. A black circle overlays the hue circle to mimic the brightness decreasing. * @param rgba * @private */ diff --git a/lib/shared/Configurator.js b/lib/shared/Configurator.js index e1d7f2bba..e564bc2d5 100644 --- a/lib/shared/Configurator.js +++ b/lib/shared/Configurator.js @@ -24,6 +24,8 @@ class Configurator { this.allowCreation = false; this.options = {}; + this.initialized = false; + this.popupCounter = 0; this.defaultOptions = { enabled: false, filter: true, @@ -35,6 +37,9 @@ class Configurator { this.configureOptions = configureOptions; this.moduleOptions = {}; this.domElements = []; + this.popupDiv = {}; + this.popupLimit = 5; + this.popupHistory = {}; this.colorPicker = new ColorPicker(pixelRatio); this.wrapper = undefined; } @@ -48,6 +53,10 @@ class Configurator { */ setOptions(options) { if (options !== undefined) { + // reset the popup history because the indices may have been changed. + this.popupHistory = {}; + this._removePopup(); + let enabled = true; if (typeof options === 'string') { this.options.filter = options; @@ -131,7 +140,7 @@ class Configurator { // a header for the category this._makeHeader(option); - // get the suboptions + // get the sub options this._handleObject(this.configureOptions[option], [option]); } counter++; @@ -140,21 +149,21 @@ class Configurator { if (this.options.showButton === true) { let generateButton = document.createElement('div'); - generateButton.className = 'vis-network-configuration button'; + generateButton.className = 'vis-configuration vis-config-button'; generateButton.innerHTML = 'generate options'; generateButton.onclick = () => {this._printOptions();}; - generateButton.onmouseover = () => {generateButton.className = 'vis-network-configuration button hover';}; - generateButton.onmouseout = () => {generateButton.className = 'vis-network-configuration button';}; + generateButton.onmouseover = () => {generateButton.className = 'vis-configuration vis-config-button hover';}; + generateButton.onmouseout = () => {generateButton.className = 'vis-configuration vis-config-button';}; this.optionsContainer = document.createElement('div'); - this.optionsContainer.className = 'vis-network-configuration vis-option-container'; + this.optionsContainer.className = 'vis-configuration vis-config-option-container'; this.domElements.push(this.optionsContainer); this.domElements.push(generateButton); } this._push(); - this.colorPicker.insertTo(this.container); + //~ this.colorPicker.insertTo(this.container); } @@ -164,11 +173,13 @@ class Configurator { */ _push() { this.wrapper = document.createElement('div'); - this.wrapper.className = 'vis-network-configuration-wrapper'; + this.wrapper.className = 'vis-configuration-wrapper'; this.container.appendChild(this.wrapper); for (var i = 0; i < this.domElements.length; i++) { this.wrapper.appendChild(this.domElements[i]); } + + this._showPopupIfNeeded() } @@ -186,6 +197,8 @@ class Configurator { this.wrapper = undefined; } this.domElements = []; + + this._removePopup(); } @@ -219,12 +232,14 @@ class Configurator { _makeItem(path, ...domElements) { if (this.allowCreation === true) { let item = document.createElement('div'); - item.className = 'vis-network-configuration item s' + path.length; + item.className = 'vis-configuration vis-config-item vis-config-s' + path.length; domElements.forEach((element) => { item.appendChild(element); }); this.domElements.push(item); + return this.domElements.length; } + return 0; } @@ -235,7 +250,7 @@ class Configurator { */ _makeHeader(name) { let div = document.createElement('div'); - div.className = 'vis-network-configuration header'; + div.className = 'vis-configuration vis-config-header'; div.innerHTML = name; this._makeItem([],div); } @@ -251,7 +266,7 @@ class Configurator { */ _makeLabel(name, path, objectLabel = false) { let div = document.createElement('div'); - div.className = 'vis-network-configuration label s' + path.length; + div.className = 'vis-configuration vis-config-label vis-config-s' + path.length; if (objectLabel === true) { div.innerHTML = '' + name + ':'; } @@ -271,7 +286,7 @@ class Configurator { */ _makeDropdown(arr, value, path) { let select = document.createElement('select'); - select.className = 'vis-network-configuration select'; + select.className = 'vis-configuration vis-config-select'; let selectedValue = 0; if (value !== undefined) { if (arr.indexOf(value) !== -1) { @@ -310,7 +325,7 @@ class Configurator { let max = arr[2]; let step = arr[3]; let range = document.createElement('input'); - range.className = 'vis-network-configuration range'; + range.className = 'vis-configuration vis-config-range'; try { range.type = 'range'; // not supported on IE9 range.min = min; @@ -319,15 +334,26 @@ class Configurator { catch (err) {} range.step = step; + // set up the popup settings in case they are needed. + let popupString = ''; + let popupValue = 0; + if (value !== undefined) { - if (value < 0 && value * 2 < min) { - range.min = value*2; + let factor = 1.20; + if (value < 0 && value * factor < min) { + range.min = Math.ceil(value * factor); + popupValue = range.min; + popupString = 'range increased'; } - else if (value * 0.1 < min) { - range.min = value / 10; + else if (value / factor < min) { + range.min = Math.ceil(value / factor); + popupValue = range.min; + popupString = 'range increased'; } - if (value * 2 > max && max !== 1) { - range.max = value * 2; + if (value * factor > max && max !== 1) { + range.max = Math.ceil(value * factor); + popupValue = range.max; + popupString = 'range increased'; } range.value = value; } @@ -336,7 +362,7 @@ class Configurator { } let input = document.createElement('input'); - input.className = 'vis-network-configuration rangeinput'; + input.className = 'vis-configuration vis-config-rangeinput'; input.value = range.value; var me = this; @@ -344,10 +370,70 @@ class Configurator { range.oninput = function () {input.value = this.value; }; let label = this._makeLabel(path[path.length-1], path); - this._makeItem(path, label, range, input); + let itemIndex = this._makeItem(path, label, range, input); + + // if a popup is needed AND it has not been shown for this value, show it. + if (popupString !== '' && this.popupHistory[itemIndex] !== popupValue) { + this.popupHistory[itemIndex] = popupValue; + this._setupPopup(popupString, itemIndex); + } + } + /** + * prepare the popup + * @param string + * @param index + * @private + */ + _setupPopup(string, index) { + if (this.initialized === true && this.allowCreation === true && this.popupCounter < this.popupLimit) { + let div = document.createElement("div"); + div.id = "vis-configuration-popup"; + div.className = "vis-configuration-popup"; + div.innerHTML = string; + div.onclick = () => {this._removePopup()}; + this.popupCounter += 1; + this.popupDiv = {html:div, index:index}; + } + } + + + /** + * remove the popup from the dom + * @private + */ + _removePopup() { + if (this.popupDiv.html !== undefined) { + this.popupDiv.html.parentNode.removeChild(this.popupDiv.html); + clearTimeout(this.popupDiv.hideTimeout); + clearTimeout(this.popupDiv.deleteTimeout); + this.popupDiv = {}; + } + } + + + /** + * Show the popup if it is needed. + * @private + */ + _showPopupIfNeeded() { + if (this.popupDiv.html !== undefined) { + let correspondingElement = this.domElements[this.popupDiv.index]; + let rect = correspondingElement.getBoundingClientRect(); + this.popupDiv.html.style.left = rect.left + "px"; + this.popupDiv.html.style.top = rect.top - 30 + "px"; // 30 is the height; + document.body.appendChild(this.popupDiv.html) + this.popupDiv.hideTimeout = setTimeout(() => { + this.popupDiv.html.style.opacity = 0; + },1500); + this.popupDiv.deleteTimeout = setTimeout(() => { + this._removePopup(); + },1800) + } + } + /** * make a checkbox for boolean options. * @param defaultValue @@ -358,7 +444,7 @@ class Configurator { _makeCheckbox(defaultValue, value, path) { var checkbox = document.createElement('input'); checkbox.type = 'checkbox'; - checkbox.className = 'vis-network-configuration checkbox'; + checkbox.className = 'vis-configuration vis-config-checkbox'; checkbox.checked = defaultValue; if (value !== undefined) { checkbox.checked = value; @@ -391,7 +477,7 @@ class Configurator { _makeTextInput(defaultValue, value, path) { var checkbox = document.createElement('input'); checkbox.type = 'text'; - checkbox.className = 'vis-network-configuration text'; + checkbox.className = 'vis-configuration vis-config-text'; checkbox.value = value; if (value !== defaultValue) { this.changedOptions.push({path:path, value:value}); @@ -418,11 +504,11 @@ class Configurator { value = value === undefined ? defaultColor : value; if (value !== 'none') { - div.className = 'vis-network-configuration colorBlock'; + div.className = 'vis-configuration vis-config-colorBlock'; div.style.backgroundColor = value; } else { - div.className = 'vis-network-configuration colorBlock none'; + div.className = 'vis-configuration vis-config-colorBlock none'; } value = value === undefined ? defaultColor : value; @@ -444,17 +530,25 @@ class Configurator { * @private */ _showColorPicker(value, div, path) { - let rect = div.getBoundingClientRect(); - let bodyRect = document.body.getBoundingClientRect(); - let pickerX = rect.left + rect.width + 5; - let pickerY = rect.top - bodyRect.top + rect.height*0.5; - this.colorPicker.show(pickerX,pickerY); + // clear the callback from this div + div.onclick = function() {}; + + this.colorPicker.insertTo(div); + this.colorPicker.show(); + this.colorPicker.setColor(value); - this.colorPicker.setCallback((color) => { + this.colorPicker.setUpdateCallback((color) => { let colorString = 'rgba(' + color.r + ',' + color.g + ',' + color.b + ',' + color.a + ')'; div.style.backgroundColor = colorString; this._update(colorString,path); - }) + }); + + // on close of the colorpicker, restore the callback. + this.colorPicker.setCloseCallback(() => { + div.onclick = () => { + this._showColorPicker(value,div,path); + }; + }); } @@ -576,7 +670,7 @@ class Configurator { if (this.parent.body && this.parent.body.emitter && this.parent.body.emitter.emit) { this.parent.body.emitter.emit("configChange", options); } - + this.initialized = true; this.parent.setOptions(options); } diff --git a/lib/network/css/network-configuration.css b/lib/shared/configuration.css similarity index 69% rename from lib/network/css/network-configuration.css rename to lib/shared/configuration.css index 4d7daecaf..1913c9885 100644 --- a/lib/network/css/network-configuration.css +++ b/lib/shared/configuration.css @@ -1,17 +1,22 @@ -div.vis-network-configuration { +div.vis-configuration { position:relative; display:block; float:left; font-size:12px; } -div.vis-network-configuration-wrapper { +div.vis-configuration-wrapper { display:block; width:700px; } +div.vis-configuration-wrapper::after { + clear: both; + content: ""; + display: block; +} -div.vis-network-configuration.vis-option-container{ +div.vis-configuration.vis-config-option-container{ display:block; width:495px; background-color: #ffffff; @@ -22,7 +27,7 @@ div.vis-network-configuration.vis-option-container{ padding-left:5px; } -div.vis-network-configuration.button{ +div.vis-configuration.vis-config-button{ display:block; width:495px; height:25px; @@ -38,13 +43,13 @@ div.vis-network-configuration.button{ margin-bottom:30px; } -div.vis-network-configuration.button.hover{ +div.vis-configuration.vis-config-button.hover{ background-color: #4588e6; border:2px solid #214373; color:#ffffff; } -div.vis-network-configuration.item{ +div.vis-configuration.vis-config-item{ display:block; float:left; width:495px; @@ -54,44 +59,44 @@ div.vis-network-configuration.item{ } -div.vis-network-configuration.item.s2{ +div.vis-configuration.vis-config-item.vis-config-s2{ left:10px; background-color: #f7f8fa; padding-left:5px; border-radius:3px; } -div.vis-network-configuration.item.s3{ +div.vis-configuration.vis-config-item.vis-config-s3{ left:20px; background-color: #e4e9f0; padding-left:5px; border-radius:3px; } -div.vis-network-configuration.item.s4{ +div.vis-configuration.vis-config-item.vis-config-s4{ left:30px; background-color: #cfd8e6; padding-left:5px; border-radius:3px; } -div.vis-network-configuration.header{ +div.vis-configuration.vis-config-header{ font-size:18px; font-weight: bold; } -div.vis-network-configuration.label{ +div.vis-configuration.vis-config-label{ width:120px; height:25px; line-height: 25px; } -div.vis-network-configuration.label.s3{ +div.vis-configuration.vis-config-label.vis-config-s3{ width:110px; } -div.vis-network-configuration.label.s4{ +div.vis-configuration.vis-config-label.vis-config-s4{ width:100px; } -div.vis-network-configuration.colorBlock{ +div.vis-configuration.vis-config-colorBlock{ top:1px; width:30px; height:19px; @@ -102,22 +107,22 @@ div.vis-network-configuration.colorBlock{ cursor:pointer; } -input.vis-network-configuration.checkbox { +input.vis-configuration.vis-config-checkbox { left:-5px; } -input.vis-network-configuration.rangeinput{ +input.vis-configuration.vis-config-rangeinput{ position:relative; top:-5px; width:60px; - height:13px; + /*height:13px;*/ padding:1px; margin:0; pointer-events:none; } -input.vis-network-configuration.range{ +input.vis-configuration.vis-config-range{ /*removes default webkit styles*/ -webkit-appearance: none; @@ -129,7 +134,7 @@ input.vis-network-configuration.range{ width: 300px; height:20px; } -input.vis-network-configuration.range::-webkit-slider-runnable-track { +input.vis-configuration.vis-config-range::-webkit-slider-runnable-track { width: 300px; height: 5px; background: #dedede; /* Old browsers */ @@ -145,7 +150,7 @@ input.vis-network-configuration.range::-webkit-slider-runnable-track { box-shadow: #aaaaaa 0px 0px 3px 0px; border-radius: 3px; } -input.vis-network-configuration.range::-webkit-slider-thumb { +input.vis-configuration.vis-config-range::-webkit-slider-thumb { -webkit-appearance: none; border: 1px solid #14334b; height: 17px; @@ -162,10 +167,10 @@ input.vis-network-configuration.range::-webkit-slider-thumb { box-shadow: #111927 0px 0px 1px 0px; margin-top: -7px; } -input.vis-network-configuration.range:focus { +input.vis-configuration.vis-config-range:focus { outline: none; } -input.vis-network-configuration.range:focus::-webkit-slider-runnable-track { +input.vis-configuration.vis-config-range:focus::-webkit-slider-runnable-track { background: #9d9d9d; /* Old browsers */ background: -moz-linear-gradient(top, #9d9d9d 0%, #c8c8c8 99%); /* FF3.6+ */ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#9d9d9d), color-stop(99%,#c8c8c8)); /* Chrome,Safari4+ */ @@ -176,7 +181,7 @@ input.vis-network-configuration.range:focus::-webkit-slider-runnable-track { filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#9d9d9d', endColorstr='#c8c8c8',GradientType=0 ); /* IE6-9 */ } -input.vis-network-configuration.range::-moz-range-track { +input.vis-configuration.vis-config-range::-moz-range-track { width: 300px; height: 10px; background: #dedede; /* Old browsers */ @@ -192,7 +197,7 @@ input.vis-network-configuration.range::-moz-range-track { box-shadow: #aaaaaa 0px 0px 3px 0px; border-radius: 3px; } -input.vis-network-configuration.range::-moz-range-thumb { +input.vis-configuration.vis-config-range::-moz-range-thumb { border: none; height: 16px; width: 16px; @@ -202,12 +207,12 @@ input.vis-network-configuration.range::-moz-range-thumb { } /*hide the outline behind the border*/ -input.vis-network-configuration.range:-moz-focusring{ +input.vis-configuration.vis-config-range:-moz-focusring{ outline: 1px solid white; outline-offset: -1px; } -input.vis-network-configuration.range::-ms-track { +input.vis-configuration.vis-config-range::-ms-track { width: 300px; height: 5px; @@ -221,24 +226,63 @@ input.vis-network-configuration.range::-ms-track { /*remove default tick marks*/ color: transparent; } -input.vis-network-configuration.range::-ms-fill-lower { +input.vis-configuration.vis-config-range::-ms-fill-lower { background: #777; border-radius: 10px; } -input.vis-network-configuration.range::-ms-fill-upper { +input.vis-configuration.vis-config-range::-ms-fill-upper { background: #ddd; border-radius: 10px; } -input.vis-network-configuration.range::-ms-thumb { +input.vis-configuration.vis-config-range::-ms-thumb { border: none; height: 16px; width: 16px; border-radius: 50%; background: #385380; } -input.vis-network-configuration.range:focus::-ms-fill-lower { +input.vis-configuration.vis-config-range:focus::-ms-fill-lower { background: #888; } -input.vis-network-configuration.range:focus::-ms-fill-upper { +input.vis-configuration.vis-config-range:focus::-ms-fill-upper { background: #ccc; +} + +.vis-configuration-popup { + position: absolute; + background: rgba(57, 76, 89, 0.85); + border: 2px solid #f2faff; + line-height:30px; + height:30px; + width:150px; + text-align:center; + color: #ffffff; + font-size:14px; + border-radius:4px; + -webkit-transition: opacity 0.3s ease-in-out; + -moz-transition: opacity 0.3s ease-in-out; + transition: opacity 0.3s ease-in-out; +} +.vis-configuration-popup:after, .vis-configuration-popup:before { + left: 100%; + top: 50%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} + +.vis-configuration-popup:after { + border-color: rgba(136, 183, 213, 0); + border-left-color: rgba(57, 76, 89, 0.85); + border-width: 8px; + margin-top: -8px; +} +.vis-configuration-popup:before { + border-color: rgba(194, 225, 245, 0); + border-left-color: #f2faff; + border-width: 12px; + margin-top: -12px; } \ No newline at end of file diff --git a/lib/timeline/Core.js b/lib/timeline/Core.js index 060dffce3..86350197a 100644 --- a/lib/timeline/Core.js +++ b/lib/timeline/Core.js @@ -90,12 +90,16 @@ Core.prototype._create = function (container) { this.dom.rightContainer.appendChild(this.dom.shadowTopRight); this.dom.rightContainer.appendChild(this.dom.shadowBottomRight); - this.on('rangechange', this.redraw.bind(this)); + this.on('rangechange', function () { + if (this.initialDrawDone === true) { + this._redraw(); // this allows overriding the _redraw method + } + }.bind(this)); this.on('touch', this._onTouch.bind(this)); this.on('pan', this._onDrag.bind(this)); var me = this; - this.on('change', function (properties) { + this.on('_change', function (properties) { if (properties && properties.queue == true) { // redraw once on next tick if (!me._redrawTimer) { @@ -179,6 +183,7 @@ Core.prototype._create = function (container) { this.touch = {}; this.redrawCount = 0; + this.initialDrawDone = false; // attach the root panel to the provided container if (!container) throw new Error('No container provided'); @@ -213,9 +218,15 @@ Core.prototype._create = function (container) { Core.prototype.setOptions = function (options) { if (options) { // copy the known options - var fields = ['width', 'height', 'minHeight', 'maxHeight', 'autoResize', 'start', 'end', 'clickToUse', 'dataAttributes', 'hiddenDates']; + var fields = [ + 'width', 'height', 'minHeight', 'maxHeight', 'autoResize', + 'start', 'end', 'clickToUse', 'dataAttributes', 'hiddenDates', + 'locale', 'locales', 'moment', + 'throttleRedraw' + ]; util.selectiveExtend(fields, this.options, options); + this.options.orientation = {item:undefined,axis:undefined}; if ('orientation' in options) { if (typeof options.orientation === 'string') { this.options.orientation = { @@ -261,9 +272,9 @@ Core.prototype.setOptions = function (options) { onRender: options.drawPoints }; } - + if ('hiddenDates' in this.options) { - DateUtil.convertHiddenOptions(this.body, this.options.hiddenDates); + DateUtil.convertHiddenOptions(this.options.moment, this.body, this.options.hiddenDates); } if ('clickToUse' in options) { @@ -307,8 +318,15 @@ Core.prototype.setOptions = function (options) { this.configurator.setModuleOptions({global: appliedOptions}); } - // redraw everything - this._redraw(); + // override redraw with a throttled version + if (!this._origRedraw) { + this._origRedraw = this._redraw.bind(this); + this._redraw = util.throttle(this._origRedraw, this.options.throttleRedraw); + } else { + // Not the initial run: redraw everything + this._redraw(); + } + }; /** @@ -396,6 +414,24 @@ Core.prototype.getCustomTime = function(id) { return customTimes[0].getCustomTime(); }; +/** + * Set a custom title for the custom time bar. + * @param {String} [title] Custom title + * @param {number} [id=undefined] Id of the custom time bar. + */ +Core.prototype.setCustomTimeTitle = function(title, id) { + var customTimes = this.customTimes.filter(function (component) { + return component.options.id === id; + }); + + if (customTimes.length === 0) { + throw new Error('No custom time bar found with id ' + JSON.stringify(id)) + } + if (customTimes.length > 0) { + return customTimes[0].setCustomTitle(title); + } +}; + /** * Retrieve meta information from an event. * Should be overridden by classes extending Core @@ -428,14 +464,14 @@ Core.prototype.addCustomTime = function (time, id) { throw new Error('A custom time with id ' + JSON.stringify(id) + ' already exists'); } - var customTime = new CustomTime(this.body, { + var customTime = new CustomTime(this.body, util.extend({}, this.options, { time : timestamp, id : id - }); + })); this.customTimes.push(customTime); this.components.push(customTime); - this.redraw(); + this._redraw(); return id; }; @@ -577,6 +613,8 @@ Core.prototype.getWindow = function() { /** * Force a redraw. Can be overridden by implementations of Core + * + * Note: this function will be overridden on construction with a trottled version */ Core.prototype.redraw = function() { this._redraw(); @@ -588,14 +626,15 @@ Core.prototype.redraw = function() { * @protected */ Core.prototype._redraw = function() { + this.redrawCount++; var resized = false; var options = this.options; var props = this.props; var dom = this.dom; - if (!dom) return; // when destroyed + if (!dom|| !dom.container || dom.container.clientWidth == 0 ) return;// when destroyed, or invisible - DateUtil.updateHiddenDates(this.body, this.options.hiddenDates); + DateUtil.updateHiddenDates(this.options.moment, this.body, this.options.hiddenDates); // update class names if (options.orientation == 'top') { @@ -733,18 +772,22 @@ Core.prototype._redraw = function() { this.components.forEach(function (component) { resized = component.redraw() || resized; }); + var MAX_REDRAW = 5; if (resized) { - // keep repainting until all sizes are settled - var MAX_REDRAWS = 3; // maximum number of consecutive redraws - if (this.redrawCount < MAX_REDRAWS) { - this.redrawCount++; - this._redraw(); + if (this.redrawCount < MAX_REDRAW) { + this.body.emitter.emit('_change'); + return; } else { console.log('WARNING: infinite loop in redraw?'); } + } else { this.redrawCount = 0; } + this.initialDrawDone = true; + + //Emit public 'changed' event for UI updates, see issue #1592 + this.body.emitter.emit("changed"); }; // TODO: deprecated since version 1.1.0, remove some day @@ -874,7 +917,7 @@ Core.prototype._startAutoResize = function () { me.props.lastWidth = me.dom.root.offsetWidth; me.props.lastHeight = me.dom.root.offsetHeight; - me.emit('change'); + me.body.emitter.emit('_change'); } } }; @@ -882,6 +925,12 @@ Core.prototype._startAutoResize = function () { // add event listener to window resize util.addEventListener(window, 'resize', this._onResize); + //Prevent initial unnecessary redraw + if (me.dom.root) { + me.props.lastWidth = me.dom.root.offsetWidth; + me.props.lastHeight = me.dom.root.offsetHeight; + } + this.watchTimer = setInterval(this._onResize, 1000); }; @@ -896,8 +945,10 @@ Core.prototype._stopAutoResize = function () { } // remove event listener on window.resize - util.removeEventListener(window, 'resize', this._onResize); - this._onResize = null; + if (this._onResize) { + util.removeEventListener(window, 'resize', this._onResize); + this._onResize = null; + } }; /** @@ -936,7 +987,6 @@ Core.prototype._onDrag = function (event) { if (newScrollTop != oldScrollTop) { - this._redraw(); // TODO: this causes two redraws when dragging, the other is triggered by rangechange already this.emit("verticalDrag"); } }; diff --git a/lib/timeline/DataStep.js b/lib/timeline/DataStep.js deleted file mode 100644 index 9bc67fecf..000000000 --- a/lib/timeline/DataStep.js +++ /dev/null @@ -1,233 +0,0 @@ -/** - * @constructor DataStep - * The class DataStep is an iterator for data for the lineGraph. You provide a start data point and an - * end data point. The class itself determines the best scale (step size) based on the - * provided start Date, end Date, and minimumStep. - * - * If minimumStep is provided, the step size is chosen as close as possible - * to the minimumStep but larger than minimumStep. If minimumStep is not - * provided, the scale is set to 1 DAY. - * The minimumStep should correspond with the onscreen size of about 6 characters - * - * Alternatively, you can set a scale by hand. - * After creation, you can initialize the class by executing first(). Then you - * can iterate from the start date to the end date via next(). You can check if - * the end date is reached with the function hasNext(). After each step, you can - * retrieve the current date via getCurrent(). - * The DataStep 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 - */ -function DataStep(start, end, minimumStep, containerHeight, customRange, formattingFunction, alignZeros) { - // variables - this.current = 0; - - this.autoScale = true; - this.stepIndex = 0; - this.step = 1; - this.scale = 1; - this.formattingFunction = formattingFunction; - - this.marginStart; - this.marginEnd; - this.deadSpace = 0; - - this.majorSteps = [1, 2, 5, 10]; - this.minorSteps = [0.25, 0.5, 1, 2]; - - this.alignZeros = alignZeros; - - this.setRange(start, end, minimumStep, containerHeight, customRange); -} - - - -/** - * 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 {Number} [start] The start date and time. - * @param {Number} [end] The end date and time. - * @param {Number} [minimumStep] Optional. Minimum step size in milliseconds - */ -DataStep.prototype.setRange = function(start, end, minimumStep, containerHeight, customRange) { - this._start = customRange.min === undefined ? start : customRange.min; - this._end = customRange.max === undefined ? end : customRange.max; - if (this._start === this._end) { - this._start = customRange.min === undefined ? this._start - 0.75 : this._start; - this._end = customRange.max === undefined ? this._end + 1 : this._end;; - } - - if (this.autoScale === true) { - this.setMinimumStep(minimumStep, containerHeight); - } - - this.setFirst(customRange); -}; - -/** - * Automatically determine the scale that bests fits the provided minimum step - * @param {Number} [minimumStep] The minimum step size in pixels - */ -DataStep.prototype.setMinimumStep = function(minimumStep, containerHeight) { - // round to floor - var range = this._end - this._start; - var safeRange = range * 1.2; - var minimumStepValue = minimumStep * (safeRange / containerHeight); - var orderOfMagnitude = Math.round(Math.log(safeRange)/Math.LN10); - - var minorStepIdx = -1; - var magnitudefactor = Math.pow(10,orderOfMagnitude); - - var start = 0; - if (orderOfMagnitude < 0) { - start = orderOfMagnitude; - } - - var solutionFound = false; - for (var i = start; Math.abs(i) <= Math.abs(orderOfMagnitude); i++) { - magnitudefactor = Math.pow(10,i); - for (var j = 0; j < this.minorSteps.length; j++) { - var stepSize = magnitudefactor * this.minorSteps[j]; - if (stepSize >= minimumStepValue) { - solutionFound = true; - minorStepIdx = j; - break; - } - } - if (solutionFound === true) { - break; - } - } - this.stepIndex = minorStepIdx; - this.scale = magnitudefactor; - this.step = magnitudefactor * this.minorSteps[minorStepIdx]; -}; - - - -/** - * Round the current date to the first minor date value - * This must be executed once when the current date is set to start Date - */ -DataStep.prototype.setFirst = function(customRange) { - if (customRange === undefined) { - customRange = {}; - } - - var niceStart = customRange.min === undefined ? this._start - (this.scale * 2 * this.minorSteps[this.stepIndex]) : customRange.min; - var niceEnd = customRange.max === undefined ? this._end + (this.scale * this.minorSteps[this.stepIndex]) : customRange.max; - - this.marginEnd = customRange.max === undefined ? this.roundToMinor(niceEnd) : customRange.max; - this.marginStart = customRange.min === undefined ? this.roundToMinor(niceStart) : customRange.min; - - // if we need to align the zero's we need to make sure that there is a zero to use. - if (this.alignZeros === true && (this.marginEnd - this.marginStart) % this.step != 0) { - this.marginEnd += this.marginEnd % this.step; - } - - this.deadSpace = this.roundToMinor(niceEnd) - niceEnd + this.roundToMinor(niceStart) - niceStart; - this.marginRange = this.marginEnd - this.marginStart; - - this.current = this.marginEnd; -}; - -DataStep.prototype.roundToMinor = function(value) { - var rounded = value - (value % (this.scale * this.minorSteps[this.stepIndex])); - if (value % (this.scale * this.minorSteps[this.stepIndex]) > 0.5 * (this.scale * this.minorSteps[this.stepIndex])) { - return rounded + (this.scale * this.minorSteps[this.stepIndex]); - } - else { - return rounded; - } -} - - -/** - * Check if the there is a next step - * @return {boolean} true if the current date has not passed the end date - */ -DataStep.prototype.hasNext = function () { - return (this.current >= this.marginStart); -}; - -/** - * Do the next step - */ -DataStep.prototype.next = function() { - var prev = this.current; - this.current -= this.step; - - // safety mechanism: if current time is still unchanged, move to the end - if (this.current === prev) { - this.current = this._end; - } -}; - -/** - * Do the next step - */ -DataStep.prototype.previous = function() { - this.current += this.step; - this.marginEnd += this.step; - this.marginRange = this.marginEnd - this.marginStart; -}; - - - -/** - * Get the current datetime - * @return {String} current The current date - */ -DataStep.prototype.getCurrent = function() { - // prevent round-off errors when close to zero - var current = (Math.abs(this.current) < this.step / 2) ? 0 : this.current; - var returnValue = current.toPrecision(5); - if (typeof this.formattingFunction === 'function') { - returnValue = this.formattingFunction(current); - } - - if (typeof returnValue === 'number') { - return '' + returnValue; - } - else if (typeof returnValue === 'string') { - return returnValue; - } - else { - return current.toPrecision(5); - } - -}; - -/** - * Check if the current value is a major value (for example when the step - * is DAY, a major value is each first day of the MONTH) - * @return {boolean} true if current date is major, else false. - */ -DataStep.prototype.isMajor = function() { - return (this.current % (this.scale * this.majorSteps[this.stepIndex]) === 0); -}; - - -DataStep.prototype.shift = function(steps) { - if (steps < 0) { - for (let i = 0; i < -steps; i++) { - this.previous(); - } - } - else if (steps > 0) { - for (let i = 0; i < steps; i++) { - this.next(); - } - } -} - -module.exports = DataStep; diff --git a/lib/timeline/DateUtil.js b/lib/timeline/DateUtil.js index 767408176..1a8a90761 100644 --- a/lib/timeline/DateUtil.js +++ b/lib/timeline/DateUtil.js @@ -1,12 +1,16 @@ -var moment = require('../module/moment'); - /** * used in Core to convert the options into a volatile variable * - * @param Core + * @param {function} moment + * @param {Object} body + * @param {Array | Object} hiddenDates */ -exports.convertHiddenOptions = function(body, hiddenDates) { +exports.convertHiddenOptions = function(moment, body, hiddenDates) { + if (hiddenDates && !Array.isArray(hiddenDates)) { + return exports.convertHiddenOptions(moment, body, [hiddenDates]) + } + body.hiddenDates = []; if (hiddenDates) { if (Array.isArray(hiddenDates) == true) { @@ -28,12 +32,17 @@ exports.convertHiddenOptions = function(body, hiddenDates) { /** * create new entrees for the repeating hidden dates - * @param body - * @param hiddenDates + * @param {function} moment + * @param {Object} body + * @param {Array | Object} hiddenDates */ -exports.updateHiddenDates = function (body, hiddenDates) { +exports.updateHiddenDates = function (moment, body, hiddenDates) { + if (hiddenDates && !Array.isArray(hiddenDates)) { + return exports.updateHiddenDates(moment, body, [hiddenDates]) + } + if (hiddenDates && body.domProps.centerContainer.width !== undefined) { - exports.convertHiddenOptions(body, hiddenDates); + exports.convertHiddenOptions(moment, body, hiddenDates); var start = moment(body.range.start); var end = moment(body.range.end); @@ -134,7 +143,7 @@ exports.updateHiddenDates = function (body, hiddenDates) { case "weekly": startDate.add(1, 'weeks'); endDate.add(1, 'weeks'); - break + break; case "monthly": startDate.add(1, 'months'); endDate.add(1, 'months'); @@ -208,20 +217,21 @@ exports.removeDuplicates = function(body) { body.hiddenDates.sort(function (a, b) { return a.start - b.start; }); // sort by start time -} +}; exports.printDates = function(dates) { for (var i =0; i < dates.length; i++) { console.log(i, new Date(dates[i].start),new Date(dates[i].end), dates[i].start, dates[i].end, dates[i].remove); } -} +}; /** * Used in TimeStep to avoid the hidden times. - * @param timeStep + * @param {function} moment + * @param {TimeStep} timeStep * @param previousTime */ -exports.stepOverHiddenDates = function(timeStep, previousTime) { +exports.stepOverHiddenDates = function(moment, timeStep, previousTime) { var stepInHidden = false; var currentValue = timeStep.current.valueOf(); for (var i = 0; i < timeStep.hiddenDates.length; i++) { @@ -241,7 +251,7 @@ exports.stepOverHiddenDates = function(timeStep, previousTime) { else if (prevValue.month() != newValue.month()) {timeStep.switchedMonth = true;} else if (prevValue.dayOfYear() != newValue.dayOfYear()) {timeStep.switchedDay = true;} - timeStep.current = newValue.toDate(); + timeStep.current = newValue; } }; @@ -282,13 +292,13 @@ exports.toScreen = function(Core, time, width) { return (time.valueOf() - conversion.offset) * conversion.scale; } else { - var hidden = exports.isHidden(time, Core.body.hiddenDates) + var hidden = exports.isHidden(time, Core.body.hiddenDates); if (hidden.hidden == true) { time = hidden.startDate; } var duration = exports.getHiddenDurationBetween(Core.body.hiddenDates, Core.range.start, Core.range.end); - time = exports.correctTimeForHidden(Core.body.hiddenDates, Core.range, time); + time = exports.correctTimeForHidden(Core.options.moment, Core.body.hiddenDates, Core.range, time); var conversion = Core.range.conversion(width, duration); return (time.valueOf() - conversion.offset) * conversion.scale; @@ -344,18 +354,19 @@ exports.getHiddenDurationBetween = function(hiddenDates, start, end) { /** * Support function + * @param moment * @param hiddenDates * @param range * @param time * @returns {{duration: number, time: *, offset: number}} */ -exports.correctTimeForHidden = function(hiddenDates, range, time) { +exports.correctTimeForHidden = function(moment, hiddenDates, range, time) { time = moment(time).toDate().valueOf(); - time -= exports.getHiddenDurationBefore(hiddenDates,range,time); + time -= exports.getHiddenDurationBefore(moment, hiddenDates,range,time); return time; }; -exports.getHiddenDurationBefore = function(hiddenDates, range, time) { +exports.getHiddenDurationBefore = function(moment, hiddenDates, range, time) { var timeOffset = 0; time = moment(time).toDate().valueOf(); diff --git a/lib/timeline/Graph2d.js b/lib/timeline/Graph2d.js index 2526193b0..a5498eb5e 100644 --- a/lib/timeline/Graph2d.js +++ b/lib/timeline/Graph2d.js @@ -1,5 +1,6 @@ var Emitter = require('emitter-component'); var Hammer = require('../module/hammer'); +var moment = require('../module/moment'); var util = require('../util'); var DataSet = require('../DataSet'); var DataView = require('../DataView'); @@ -26,7 +27,7 @@ var configureOptions = require('./optionsGraph2d').configureOptions; */ function Graph2d (container, items, groups, options) { // if the third element is options, the forth is groups (optionally); - if (!(Array.isArray(groups) || groups instanceof DataSet) && groups instanceof Object) { + if (!(Array.isArray(groups) || groups instanceof DataSet || groups instanceof DataView) && groups instanceof Object) { var forthArgument = options; options = groups; groups = forthArgument; @@ -44,6 +45,8 @@ function Graph2d (container, items, groups, options) { item: 'bottom' // not relevant for Graph2d }, + moment: moment, + width: null, height: null, maxHeight: null, @@ -90,11 +93,13 @@ function Graph2d (container, items, groups, options) { // item set this.linegraph = new LineGraph(this.body); + this.components.push(this.linegraph); this.itemsData = null; // DataSet this.groupsData = null; // DataSet + this.on('tap', function (event) { me.emit('click', me.getEventProperties(event)) }); @@ -119,9 +124,9 @@ function Graph2d (container, items, groups, options) { if (items) { this.setItems(items); } - else { - this._redraw(); - } + + // draw for the first time + this._redraw(); } // Extend the functionality from Core @@ -170,7 +175,6 @@ Graph2d.prototype.setItems = function(items) { if (this.options.start != undefined || this.options.end != undefined) { var start = this.options.start != undefined ? this.options.start : null; var end = this.options.end != undefined ? this.options.end : null; - this.setWindow(start, end, {animation: false}); } else { @@ -214,7 +218,7 @@ Graph2d.prototype.getLegend = function(groupId, width, height) { return this.linegraph.groups[groupId].getLegend(width,height); } else { - return "cannot find group:" + groupId; + return "cannot find group:'" + groupId + "'"; } }; diff --git a/lib/timeline/Range.js b/lib/timeline/Range.js index 5bf5d403c..97e690e2f 100644 --- a/lib/timeline/Range.js +++ b/lib/timeline/Range.js @@ -27,6 +27,7 @@ function Range(body, options) { this.defaultOptions = { start: null, end: null, + moment: moment, direction: 'horizontal', // 'horizontal' or 'vertical' moveable: true, zoomable: true, @@ -78,7 +79,10 @@ Range.prototype = new Component(); Range.prototype.setOptions = function (options) { if (options) { // copy the options that we know - var fields = ['direction', 'min', 'max', 'zoomMin', 'zoomMax', 'moveable', 'zoomable', 'activate', 'hiddenDates', 'zoomKey']; + var fields = [ + 'direction', 'min', 'max', 'zoomMin', 'zoomMax', 'moveable', 'zoomable', + 'moment', 'activate', 'hiddenDates', 'zoomKey' + ]; util.selectiveExtend(fields, this.options, options); if ('start' in options || 'end' in options) { @@ -145,7 +149,7 @@ Range.prototype.setRange = function(start, end, animation, byUser) { var e = (done || finalEnd === null) ? finalEnd : initEnd + (finalEnd - initEnd) * ease; changed = me._applyRange(s, e); - DateUtil.updateHiddenDates(me.body, me.options.hiddenDates); + DateUtil.updateHiddenDates(me.options.moment, me.body, me.options.hiddenDates); anyChanged = anyChanged || changed; if (changed) { me.body.emitter.emit('rangechange', {start: new Date(me.start), end: new Date(me.end), byUser:byUser}); @@ -168,7 +172,7 @@ Range.prototype.setRange = function(start, end, animation, byUser) { } else { var changed = this._applyRange(finalStart, finalEnd); - DateUtil.updateHiddenDates(this.body, this.options.hiddenDates); + DateUtil.updateHiddenDates(this.options.moment, this.body, this.options.hiddenDates); if (changed) { var params = {start: new Date(this.start), end: new Date(this.end), byUser:byUser}; this.body.emitter.emit('rangechange', params); @@ -425,10 +429,14 @@ Range.prototype._onDrag = function (event) { this.previousDelta = delta; this._applyRange(newStart, newEnd); + + var startDate = new Date(this.start); + var endDate = new Date(this.end); + // fire a rangechange event this.body.emitter.emit('rangechange', { - start: new Date(this.start), - end: new Date(this.end), + start: startDate, + end: endDate, byUser: true }); }; @@ -548,7 +556,7 @@ Range.prototype._onPinch = function (event) { var centerDate = this._pointerToDate(this.props.touch.center); var hiddenDuration = DateUtil.getHiddenDurationBetween(this.body.hiddenDates, this.start, this.end); - var hiddenDurationBefore = DateUtil.getHiddenDurationBefore(this.body.hiddenDates, this, centerDate); + var hiddenDurationBefore = DateUtil.getHiddenDurationBefore(this.options.moment, this.body.hiddenDates, this, centerDate); var hiddenDurationAfter = hiddenDuration - hiddenDurationBefore; // calculate new start and end @@ -645,7 +653,7 @@ Range.prototype.zoom = function(scale, center, delta) { } var hiddenDuration = DateUtil.getHiddenDurationBetween(this.body.hiddenDates, this.start, this.end); - var hiddenDurationBefore = DateUtil.getHiddenDurationBefore(this.body.hiddenDates, this, center); + var hiddenDurationBefore = DateUtil.getHiddenDurationBefore(this.options.moment, this.body.hiddenDates, this, center); var hiddenDurationAfter = hiddenDuration - hiddenDurationBefore; // calculate new start and end diff --git a/lib/timeline/TimeStep.js b/lib/timeline/TimeStep.js index 8ba9070d1..e7cb1888f 100644 --- a/lib/timeline/TimeStep.js +++ b/lib/timeline/TimeStep.js @@ -29,10 +29,12 @@ var util = require('../util'); * @param {Number} [minimumStep] Optional. Minimum step size in milliseconds */ function TimeStep(start, end, minimumStep, hiddenDates) { + this.moment = moment; + // variables - this.current = new Date(); - this._start = new Date(); - this._end = new Date(); + this.current = this.moment(); + this._start = this.moment(); + this._end = this.moment(); this.autoScale = true; this.scale = 'day'; @@ -45,8 +47,13 @@ function TimeStep(start, end, minimumStep, hiddenDates) { this.switchedDay = false; this.switchedMonth = false; this.switchedYear = false; - this.hiddenDates = hiddenDates; - if (hiddenDates === undefined) { + if (Array.isArray(hiddenDates)) { + this.hiddenDates = hiddenDates; + } + else if (hiddenDates != undefined) { + this.hiddenDates = [hiddenDates]; + } + else { this.hiddenDates = []; } @@ -77,6 +84,20 @@ TimeStep.FORMAT = { } }; +/** + * Set custom constructor function for moment. Can be used to set dates + * to UTC or to set a utcOffset. + * @param {function} moment + */ +TimeStep.prototype.setMoment = function (moment) { + this.moment = moment; + + // update the date properties, can have a new utcOffset + this.current = this.moment(this.current); + this._start = this.moment(this._start); + this._end = this.moment(this._end); +}; + /** * Set custom formatting for the minor an major labels of the TimeStep. * Both `minorLabels` and `majorLabels` are an Object with properties: @@ -103,8 +124,8 @@ TimeStep.prototype.setRange = function(start, end, minimumStep) { throw "No legal start or end date in method setRange"; } - this._start = (start != undefined) ? new Date(start.valueOf()) : new Date(); - this._end = (end != undefined) ? new Date(end.valueOf()) : new Date(); + this._start = (start != undefined) ? this.moment(start.valueOf()) : new Date(); + this._end = (end != undefined) ? this.moment(end.valueOf()) : new Date(); if (this.autoScale) { this.setMinimumStep(minimumStep); @@ -114,8 +135,8 @@ TimeStep.prototype.setRange = function(start, end, minimumStep) { /** * Set the range iterator to the start date. */ -TimeStep.prototype.first = function() { - this.current = new Date(this._start.valueOf()); +TimeStep.prototype.start = function() { + this.current = this._start.clone(); this.roundToMinor(); }; @@ -129,28 +150,28 @@ TimeStep.prototype.roundToMinor = function() { // noinspection FallThroughInSwitchStatementJS switch (this.scale) { case 'year': - this.current.setFullYear(this.step * Math.floor(this.current.getFullYear() / this.step)); - this.current.setMonth(0); - case 'month': this.current.setDate(1); + this.current.year(this.step * Math.floor(this.current.year() / this.step)); + this.current.month(0); + case 'month': this.current.date(1); case 'day': // intentional fall through - case 'weekday': this.current.setHours(0); - case 'hour': this.current.setMinutes(0); - case 'minute': this.current.setSeconds(0); - case 'second': this.current.setMilliseconds(0); + case 'weekday': this.current.hours(0); + case 'hour': this.current.minutes(0); + case 'minute': this.current.seconds(0); + case 'second': this.current.milliseconds(0); //case '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 'millisecond': this.current.setMilliseconds(this.current.getMilliseconds() - this.current.getMilliseconds() % this.step); break; - case 'second': this.current.setSeconds(this.current.getSeconds() - this.current.getSeconds() % this.step); break; - case 'minute': this.current.setMinutes(this.current.getMinutes() - this.current.getMinutes() % this.step); break; - case 'hour': this.current.setHours(this.current.getHours() - this.current.getHours() % this.step); break; + case 'millisecond': this.current.subtract(this.current.milliseconds() % this.step, 'milliseconds'); break; + case 'second': this.current.subtract(this.current.seconds() % this.step, 'seconds'); break; + case 'minute': this.current.subtract(this.current.minutes() % this.step, 'minutes'); break; + case 'hour': this.current.subtract(this.current.hours() % this.step, 'hours'); break; case 'weekday': // intentional fall through - case 'day': this.current.setDate((this.current.getDate()-1) - (this.current.getDate()-1) % this.step + 1); break; - case 'month': this.current.setMonth(this.current.getMonth() - this.current.getMonth() % this.step); break; - case 'year': this.current.setFullYear(this.current.getFullYear() - this.current.getFullYear() % this.step); break; + case 'day': this.current.subtract((this.current.date() - 1) % this.step, 'day'); break; + case 'month': this.current.subtract(this.current.month() % this.step, 'month'); break; + case 'year': this.current.subtract(this.current.year() % this.step, 'year'); break; default: break; } } @@ -172,67 +193,65 @@ TimeStep.prototype.next = function() { // Two cases, needed to prevent issues with switching daylight savings // (end of March and end of October) - if (this.current.getMonth() < 6) { + if (this.current.month() < 6) { switch (this.scale) { - case 'millisecond': - - this.current = new Date(this.current.valueOf() + this.step); break; - case 'second': this.current = new Date(this.current.valueOf() + this.step * 1000); break; - case 'minute': this.current = new Date(this.current.valueOf() + this.step * 1000 * 60); break; + case 'millisecond': this.current.add(this.step, 'millisecond'); break; + case 'second': this.current.add(this.step, 'second'); break; + case 'minute': this.current.add(this.step, 'minute'); break; case 'hour': - this.current = new Date(this.current.valueOf() + this.step * 1000 * 60 * 60); + this.current.add(this.step, 'hour'); // 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)); + // TODO: is this still needed now we use the function of moment.js? + this.current.subtract(this.current.hours() % this.step, 'hour'); break; case 'weekday': // intentional fall through - case 'day': this.current.setDate(this.current.getDate() + this.step); break; - case 'month': this.current.setMonth(this.current.getMonth() + this.step); break; - case 'year': this.current.setFullYear(this.current.getFullYear() + this.step); break; - default: break; + case 'day': this.current.add(this.step, 'day'); break; + case 'month': this.current.add(this.step, 'month'); break; + case 'year': this.current.add(this.step, 'year'); break; + default: break; } } else { switch (this.scale) { - case 'millisecond': this.current = new Date(this.current.valueOf() + this.step); break; - case 'second': this.current.setSeconds(this.current.getSeconds() + this.step); break; - case 'minute': this.current.setMinutes(this.current.getMinutes() + this.step); break; - case 'hour': this.current.setHours(this.current.getHours() + this.step); break; + case 'millisecond': this.current.add(this.step, 'millisecond'); break; + case 'second': this.current.add(this.step, 'second'); break; + case 'minute': this.current.add(this.step, 'minute'); break; + case 'hour': this.current.add(this.step, 'hour'); break; case 'weekday': // intentional fall through - case 'day': this.current.setDate(this.current.getDate() + this.step); break; - case 'month': this.current.setMonth(this.current.getMonth() + this.step); break; - case 'year': this.current.setFullYear(this.current.getFullYear() + this.step); break; - default: break; + case 'day': this.current.add(this.step, 'day'); break; + case 'month': this.current.add(this.step, 'month'); break; + case 'year': this.current.add(this.step, 'year'); break; + default: break; } } if (this.step != 1) { // round down to the correct major value switch (this.scale) { - case 'millisecond': if(this.current.getMilliseconds() < this.step) this.current.setMilliseconds(0); break; - case 'second': if(this.current.getSeconds() < this.step) this.current.setSeconds(0); break; - case 'minute': if(this.current.getMinutes() < this.step) this.current.setMinutes(0); break; - case 'hour': if(this.current.getHours() < this.step) this.current.setHours(0); break; + case 'millisecond': if(this.current.milliseconds() < this.step) this.current.milliseconds(0); break; + case 'second': if(this.current.seconds() < this.step) this.current.seconds(0); break; + case 'minute': if(this.current.minutes() < this.step) this.current.minutes(0); break; + case 'hour': if(this.current.hours() < this.step) this.current.hours(0); break; case 'weekday': // intentional fall through - case 'day': if(this.current.getDate() < this.step+1) this.current.setDate(1); break; - case 'month': if(this.current.getMonth() < this.step) this.current.setMonth(0); break; + case 'day': if(this.current.date() < this.step+1) this.current.date(1); break; + case 'month': if(this.current.month() < this.step) this.current.month(0); break; case 'year': break; // nothing to do for year - default: break; + 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()); + this.current = this._end.clone(); } - DateUtil.stepOverHiddenDates(this, prev); + DateUtil.stepOverHiddenDates(this.moment, this, prev); }; /** * Get the current datetime - * @return {Date} current The current date + * @return {Moment} current The current date */ TimeStep.prototype.getCurrent = function() { return this.current; @@ -329,100 +348,100 @@ TimeStep.prototype.setMinimumStep = function(minimumStep) { * @return {Date} snappedDate */ TimeStep.snap = function(date, scale, step) { - var clone = new Date(date.valueOf()); + var clone = moment(date); if (scale == 'year') { - var year = clone.getFullYear() + Math.round(clone.getMonth() / 12); - clone.setFullYear(Math.round(year / step) * step); - clone.setMonth(0); - clone.setDate(0); - clone.setHours(0); - clone.setMinutes(0); - clone.setSeconds(0); - clone.setMilliseconds(0); + var year = clone.year() + Math.round(clone.month() / 12); + clone.year(Math.round(year / step) * step); + clone.month(0); + clone.date(0); + clone.hours(0); + clone.minutes(0); + clone.seconds(0); + clone.milliseconds(0); } else if (scale == 'month') { - if (clone.getDate() > 15) { - clone.setDate(1); - clone.setMonth(clone.getMonth() + 1); + if (clone.date() > 15) { + clone.date(1); + clone.add(1, 'month'); // important: first set Date to 1, after that change the month. } else { - clone.setDate(1); + clone.date(1); } - clone.setHours(0); - clone.setMinutes(0); - clone.setSeconds(0); - clone.setMilliseconds(0); + clone.hours(0); + clone.minutes(0); + clone.seconds(0); + clone.milliseconds(0); } else if (scale == 'day') { //noinspection FallthroughInSwitchStatementJS switch (step) { case 5: case 2: - clone.setHours(Math.round(clone.getHours() / 24) * 24); break; + clone.hours(Math.round(clone.hours() / 24) * 24); break; default: - clone.setHours(Math.round(clone.getHours() / 12) * 12); break; + clone.hours(Math.round(clone.hours() / 12) * 12); break; } - clone.setMinutes(0); - clone.setSeconds(0); - clone.setMilliseconds(0); + clone.minutes(0); + clone.seconds(0); + clone.milliseconds(0); } else if (scale == 'weekday') { //noinspection FallthroughInSwitchStatementJS switch (step) { case 5: case 2: - clone.setHours(Math.round(clone.getHours() / 12) * 12); break; + clone.hours(Math.round(clone.hours() / 12) * 12); break; default: - clone.setHours(Math.round(clone.getHours() / 6) * 6); break; + clone.hours(Math.round(clone.hours() / 6) * 6); break; } - clone.setMinutes(0); - clone.setSeconds(0); - clone.setMilliseconds(0); + clone.minutes(0); + clone.seconds(0); + clone.milliseconds(0); } else if (scale == 'hour') { switch (step) { case 4: - clone.setMinutes(Math.round(clone.getMinutes() / 60) * 60); break; + clone.minutes(Math.round(clone.minutes() / 60) * 60); break; default: - clone.setMinutes(Math.round(clone.getMinutes() / 30) * 30); break; + clone.minutes(Math.round(clone.minutes() / 30) * 30); break; } - clone.setSeconds(0); - clone.setMilliseconds(0); + clone.seconds(0); + clone.milliseconds(0); } else if (scale == 'minute') { //noinspection FallthroughInSwitchStatementJS switch (step) { case 15: case 10: - clone.setMinutes(Math.round(clone.getMinutes() / 5) * 5); - clone.setSeconds(0); + clone.minutes(Math.round(clone.minutes() / 5) * 5); + clone.seconds(0); break; case 5: - clone.setSeconds(Math.round(clone.getSeconds() / 60) * 60); break; + clone.seconds(Math.round(clone.seconds() / 60) * 60); break; default: - clone.setSeconds(Math.round(clone.getSeconds() / 30) * 30); break; + clone.seconds(Math.round(clone.seconds() / 30) * 30); break; } - clone.setMilliseconds(0); + clone.milliseconds(0); } else if (scale == 'second') { //noinspection FallthroughInSwitchStatementJS switch (step) { case 15: case 10: - clone.setSeconds(Math.round(clone.getSeconds() / 5) * 5); - clone.setMilliseconds(0); + clone.seconds(Math.round(clone.seconds() / 5) * 5); + clone.milliseconds(0); break; case 5: - clone.setMilliseconds(Math.round(clone.getMilliseconds() / 1000) * 1000); break; + clone.milliseconds(Math.round(clone.milliseconds() / 1000) * 1000); break; default: - clone.setMilliseconds(Math.round(clone.getMilliseconds() / 500) * 500); break; + clone.milliseconds(Math.round(clone.milliseconds() / 500) * 500); break; } } else if (scale == 'millisecond') { var _step = step > 5 ? step / 2 : 1; - clone.setMilliseconds(Math.round(clone.getMilliseconds() / _step) * _step); + clone.milliseconds(Math.round(clone.milliseconds() / _step) * _step); } return clone; @@ -477,20 +496,21 @@ TimeStep.prototype.isMajor = function() { } } + var date = this.moment(this.current); switch (this.scale) { case 'millisecond': - return (this.current.getMilliseconds() == 0); + return (date.milliseconds() == 0); case 'second': - return (this.current.getSeconds() == 0); + return (date.seconds() == 0); case 'minute': - return (this.current.getHours() == 0) && (this.current.getMinutes() == 0); + return (date.hours() == 0) && (date.minutes() == 0); case 'hour': - return (this.current.getHours() == 0); + return (date.hours() == 0); case 'weekday': // intentional fall through case 'day': - return (this.current.getDate() == 1); + return (date.date() == 1); case 'month': - return (this.current.getMonth() == 0); + return (date.month() == 0); case 'year': return false; default: @@ -511,7 +531,7 @@ TimeStep.prototype.getLabelMinor = function(date) { } var format = this.format.minorLabels[this.scale]; - return (format && format.length > 0) ? moment(date).format(format) : ''; + return (format && format.length > 0) ? this.moment(date).format(format) : ''; }; /** @@ -526,12 +546,13 @@ TimeStep.prototype.getLabelMajor = function(date) { } var format = this.format.majorLabels[this.scale]; - return (format && format.length > 0) ? moment(date).format(format) : ''; + return (format && format.length > 0) ? this.moment(date).format(format) : ''; }; TimeStep.prototype.getClassName = function() { - var m = moment(this.current); - var date = m.locale ? m.locale('en') : m.lang('en'); // old versions of moment have .lang() function + var _moment = this.moment; + var m = this.moment(this.current); + var current = m.locale ? m.locale('en') : m.lang('en'); // old versions of moment have .lang() function var step = this.step; function even(value) { @@ -542,10 +563,10 @@ TimeStep.prototype.getClassName = function() { if (date.isSame(new Date(), 'day')) { return ' vis-today'; } - if (date.isSame(moment().add(1, 'day'), 'day')) { + if (date.isSame(_moment().add(1, 'day'), 'day')) { return ' vis-tomorrow'; } - if (date.isSame(moment().add(-1, 'day'), 'day')) { + if (date.isSame(_moment().add(-1, 'day'), 'day')) { return ' vis-yesterday'; } return ''; @@ -565,37 +586,37 @@ TimeStep.prototype.getClassName = function() { switch (this.scale) { case 'millisecond': - return even(date.milliseconds()).trim(); + return even(current.milliseconds()).trim(); case 'second': - return even(date.seconds()).trim(); + return even(current.seconds()).trim(); case 'minute': - return even(date.minutes()).trim(); + return even(current.minutes()).trim(); case 'hour': - var hours = date.hours(); + var hours = current.hours(); if (this.step == 4) { hours = hours + '-h' + (hours + 4); } - return 'vis-h' + hours + today(date) + even(date.hours()); + return 'vis-h' + hours + today(current) + even(current.hours()); case 'weekday': - return 'vis-' + date.format('dddd').toLowerCase() + - today(date) + currentWeek(date) + even(date.date()); + return 'vis-' + current.format('dddd').toLowerCase() + + today(current) + currentWeek(current) + even(current.date()); case 'day': - var day = date.date(); - var month = date.format('MMMM').toLowerCase(); - return 'vis-day' + day + ' vis-' + month + currentMonth(date) + even(day - 1); + var day = current.date(); + var month = current.format('MMMM').toLowerCase(); + return 'vis-day' + day + ' vis-' + month + currentMonth(current) + even(day - 1); case 'month': - return 'vis-' + date.format('MMMM').toLowerCase() + - currentMonth(date) + even(date.month()); + return 'vis-' + current.format('MMMM').toLowerCase() + + currentMonth(current) + even(current.month()); case 'year': - var year = date.year(); - return 'vis-year' + year + currentYear(date)+ even(year); + var year = current.year(); + return 'vis-year' + year + currentYear(current)+ even(year); default: return ''; diff --git a/lib/timeline/Timeline.js b/lib/timeline/Timeline.js index ad06adb4a..e2bfd5551 100644 --- a/lib/timeline/Timeline.js +++ b/lib/timeline/Timeline.js @@ -1,5 +1,6 @@ var Emitter = require('emitter-component'); var Hammer = require('../module/hammer'); +var moment = require('../module/moment'); var util = require('../util'); var DataSet = require('../DataSet'); var DataView = require('../DataView'); @@ -43,12 +44,15 @@ function Timeline (container, items, groups, options) { end: null, autoResize: true, + throttleRedraw: 0, // ms orientation: { axis: 'bottom', // axis orientation: 'bottom', 'top', or 'both' item: 'bottom' // not relevant }, + moment: moment, + width: null, height: null, maxHeight: null, @@ -117,6 +121,28 @@ function Timeline (container, items, groups, options) { me.emit('contextmenu', me.getEventProperties(event)) }; + //Single time autoscale/fit + this.fitDone = false; + this.on('changed', function (){ + if (this.itemsData == null) return; + if (!me.fitDone) { + me.fitDone = true; + if (me.options.start != undefined || me.options.end != undefined) { + if (me.options.start == undefined || me.options.end == undefined) { + var range = me.getItemRange(); + } + + var start = me.options.start != undefined ? me.options.start : range.min; + var end = me.options.end != undefined ? me.options.end : range.max; + + me.setWindow(start, end, {animation: false}); + } + else { + me.fit({animation: false}); + } + } + }); + // apply options if (options) { this.setOptions(options); @@ -131,9 +157,9 @@ function Timeline (container, items, groups, options) { if (items) { this.setItems(items); } - else { - this._redraw(); - } + + // draw for the first time + this._redraw(); } // Extend the functionality from Core @@ -152,6 +178,8 @@ Timeline.prototype._createConfigurator = function () { * Force a redraw. The size of all items will be recalculated. * Can be useful to manually redraw when option autoResize=false and the window * has been resized, or when the items CSS has been changed. + * + * Note: this function will be overridden on construction with a trottled version */ Timeline.prototype.redraw = function() { this.itemSet && this.itemSet.markDirty({refreshItems: true}); @@ -188,8 +216,6 @@ Timeline.prototype.setOptions = function (options) { * @param {vis.DataSet | Array | null} items */ Timeline.prototype.setItems = function(items) { - var initialLoad = (this.itemsData == null); - // convert to type DataSet when needed var newDataSet; if (!items) { @@ -211,22 +237,6 @@ Timeline.prototype.setItems = function(items) { // set items this.itemsData = newDataSet; this.itemSet && this.itemSet.setItems(newDataSet); - - if (initialLoad) { - if (this.options.start != undefined || this.options.end != undefined) { - if (this.options.start == undefined || this.options.end == undefined) { - var range = this.getItemRange(); - } - - var start = this.options.start != undefined ? this.options.start : range.min; - var end = this.options.end != undefined ? this.options.end : range.max; - - this.setWindow(start, end, {animation: false}); - } - else { - this.fit({animation: false}); - } - } }; /** @@ -373,8 +383,8 @@ Timeline.prototype.fit = function (options) { Timeline.prototype.getItemRange = function () { // get a rough approximation for the range based on the items start and end dates var range = this.getDataRange(); - var min = range.min; - var max = range.max; + var min = range.min !== null ? range.min.valueOf() : null; + var max = range.max !== null ? range.max.valueOf() : null; var minItem = null; var maxItem = null; @@ -397,12 +407,13 @@ Timeline.prototype.getItemRange = function () { // calculate the date of the left side and right side of the items given util.forEach(this.itemSet.items, function (item) { item.show(); + item.repositionX(); var start = getStart(item); var end = getEnd(item); - var left = new Date(start - (item.getWidthLeft() + 10) * factor); - var right = new Date(end + (item.getWidthRight() + 10) * factor); + var left = start - (item.getWidthLeft() + 10) * factor; + var right = end + (item.getWidthRight() + 10) * factor; if (left < min) { min = left; @@ -449,7 +460,7 @@ Timeline.prototype.getDataRange = function() { min = start; } if (max === null || end > max) { - max = start; + max = end; } }); } diff --git a/lib/timeline/component/CurrentTime.js b/lib/timeline/component/CurrentTime.js index e07514627..d23ba377d 100644 --- a/lib/timeline/component/CurrentTime.js +++ b/lib/timeline/component/CurrentTime.js @@ -18,6 +18,7 @@ function CurrentTime (body, options) { this.defaultOptions = { showCurrentTime: true, + moment: moment, locales: locales, locale: 'en' }; @@ -63,7 +64,7 @@ CurrentTime.prototype.destroy = function () { CurrentTime.prototype.setOptions = function(options) { if (options) { // copy all options that we know - util.selectiveExtend(['showCurrentTime', 'locale', 'locales'], this.options, options); + util.selectiveExtend(['showCurrentTime', 'moment', 'locale', 'locales'], this.options, options); } }; @@ -84,18 +85,18 @@ CurrentTime.prototype.redraw = function() { this.start(); } - var now = new Date(new Date().valueOf() + this.offset); + var now = this.options.moment(new Date().valueOf() + this.offset); var x = this.body.util.toScreen(now); var locale = this.options.locales[this.options.locale]; if (!locale) { if (!this.warned) { - console.log('WARNING: options.locales[\'' + this.options.locale + '\'] not found. See http://visjs.org/docs/timeline.html#Localization'); + console.log('WARNING: options.locales[\'' + this.options.locale + '\'] not found. See http://visjs.org/docs/timeline/#Localization'); this.warned = true; } locale = this.options.locales['en']; // fall back on english when not available } - var title = locale.current + ' ' + locale.time + ': ' + moment(now).format('dddd, MMMM Do YYYY, H:mm:ss'); + var title = locale.current + ' ' + locale.time + ': ' + now.format('dddd, MMMM Do YYYY, H:mm:ss'); title = title.charAt(0).toUpperCase() + title.substring(1); this.bar.style.left = x + 'px'; diff --git a/lib/timeline/component/CustomTime.js b/lib/timeline/component/CustomTime.js index 1bd3634db..72ee0000e 100644 --- a/lib/timeline/component/CustomTime.js +++ b/lib/timeline/component/CustomTime.js @@ -20,18 +20,20 @@ function CustomTime (body, options) { // default options this.defaultOptions = { + moment: moment, locales: locales, locale: 'en', - id: undefined + id: undefined, + title: undefined }; this.options = util.extend({}, this.defaultOptions); if (options && options.time) { this.customTime = options.time; } else { - this.customTime = new Date(); + this.customTime = new Date(); } - + this.eventParams = {}; // stores state parameters while dragging the bar this.setOptions(options); @@ -52,7 +54,7 @@ CustomTime.prototype = new Component(); CustomTime.prototype.setOptions = function(options) { if (options) { // copy all options that we know - util.selectiveExtend(['locale', 'locales', 'id'], this.options, options); + util.selectiveExtend(['moment', 'locale', 'locales', 'id'], this.options, options); } }; @@ -83,10 +85,6 @@ CustomTime.prototype._create = function() { this.hammer.on('panmove', this._onDrag.bind(this)); this.hammer.on('panend', this._onDragEnd.bind(this)); this.hammer.get('pan').set({threshold:5, direction:30}); // 30 is ALL_DIRECTIONS in hammer. - // TODO: cleanup - //this.hammer.on('pan', function (event) { - // event.preventDefault(); - //}); }; /** @@ -125,8 +123,13 @@ CustomTime.prototype.redraw = function () { } locale = this.options.locales['en']; // fall back on english when not available } - var title = locale.time + ': ' + moment(this.customTime).format('dddd, MMMM Do YYYY, H:mm:ss'); - title = title.charAt(0).toUpperCase() + title.substring(1); + + var title = this.options.title; + // To hide the title completely use empty string ''. + if (title === undefined) { + title = locale.time + ': ' + this.options.moment(this.customTime).format('dddd, MMMM Do YYYY, H:mm:ss'); + title = title.charAt(0).toUpperCase() + title.substring(1); + } this.bar.style.left = x + 'px'; this.bar.title = title; @@ -161,6 +164,14 @@ CustomTime.prototype.getCustomTime = function() { return new Date(this.customTime.valueOf()); }; +/** + * Set custom title. + * @param {Date | number | string} title + */ +CustomTime.prototype.setCustomTitle = function(title) { + this.options.title = title; +}; + /** * Start moving horizontally * @param {Event} event diff --git a/lib/timeline/component/DataAxis.js b/lib/timeline/component/DataAxis.js index 8e6732076..0f3ec598b 100644 --- a/lib/timeline/component/DataAxis.js +++ b/lib/timeline/component/DataAxis.js @@ -1,8 +1,7 @@ var util = require('../../util'); var DOMutil = require('../../DOMutil'); var Component = require('./Component'); -var DataStep = require('../DataStep'); - +var DataScale = require('./DataScale'); /** * A horizontal time axis * @param {Object} [options] See DataAxis.setOptions for the available @@ -19,7 +18,7 @@ function DataAxis (body, options, svg, linegraphOptions) { orientation: 'left', // supported: 'left', 'right' showMinorLabels: true, showMajorLabels: true, - icons: true, + icons: false, majorLinesOffset: 7, minorLinesOffset: 4, labelOffsetX: 10, @@ -30,12 +29,12 @@ function DataAxis (body, options, svg, linegraphOptions) { alignZeros: true, left:{ range: {min:undefined,max:undefined}, - format: function (value) {return value;}, + format: function (value) {return ''+Number.parseFloat(value.toPrecision(3));}, title: {text:undefined,style:undefined} }, right:{ range: {min:undefined,max:undefined}, - format: function (value) {return value;}, + format: function (value) {return ''+Number.parseFloat(value.toPrecision(3));}, title: {text:undefined,style:undefined} } }; @@ -50,7 +49,7 @@ function DataAxis (body, options, svg, linegraphOptions) { }; this.dom = {}; - + this.scale= undefined; this.range = {start:0, end:0}; this.options = util.extend({}, this.defaultOptions); @@ -59,7 +58,7 @@ function DataAxis (body, options, svg, linegraphOptions) { this.setOptions(options); this.width = Number(('' + this.options.width).replace("px","")); this.minWidth = this.width; - this.height = this.linegraphSVG.offsetHeight; + this.height = this.linegraphSVG.getBoundingClientRect().height; this.hidden = false; this.stepPixels = 25; @@ -68,15 +67,16 @@ function DataAxis (body, options, svg, linegraphOptions) { this.lineOffset = 0; this.master = true; + this.masterAxis = null; this.svgElements = {}; this.iconsRemoved = false; - this.groups = {}; this.amountOfGroups = 0; // create the HTML DOM this._create(); + this.framework = {svg: this.svg, svgElements: this.svgElements, options: this.options, groups: this.groups}; var me = this; this.body.emitter.on("verticalDrag", function() { @@ -95,6 +95,9 @@ DataAxis.prototype.addGroup = function(label, graphOptions) { }; DataAxis.prototype.updateGroup = function(label, graphOptions) { + if (!this.groups.hasOwnProperty(label)) { + this.amountOfGroups += 1; + } this.groups[label] = graphOptions; }; @@ -128,10 +131,9 @@ DataAxis.prototype.setOptions = function (options) { 'right', 'alignZeros' ]; - util.selectiveExtend(fields, this.options, options); + util.selectiveDeepExtend(fields, this.options, options); this.minWidth = Number(('' + this.options.width).replace("px","")); - if (redraw === true && this.dom.frame) { this.hide(); this.show(); @@ -187,7 +189,7 @@ DataAxis.prototype._redrawGroupIcons = function () { for (var i = 0; i < groupArray.length; i++) { var groupId = groupArray[i]; if (this.groups[groupId].visible === true && (this.linegraphOptions.visibility[groupId] === undefined || this.linegraphOptions.visibility[groupId] === true)) { - this.groups[groupId].drawIcon(x, y, this.svgElements, this.svg, iconWidth, iconHeight); + this.groups[groupId].getLegend(iconWidth, iconHeight, this.framework, x, y); y += iconHeight + iconOffset; } } @@ -244,11 +246,6 @@ DataAxis.prototype.hide = function() { * @param end */ DataAxis.prototype.setRange = function (start, end) { - if (this.master === false && this.options.alignZeros === true && this.zeroCrossing != -1) { - if (start > 0) { - start = 0; - } - } this.range.start = start; this.range.end = end; }; @@ -260,7 +257,7 @@ DataAxis.prototype.setRange = function (start, end) { DataAxis.prototype.redraw = function () { var resized = false; var activeGroups = 0; - + // Make sure the line container adheres to the vertical scrolling. this.dom.lineContainer.style.top = this.body.domProps.scrollTop + 'px'; @@ -348,105 +345,60 @@ DataAxis.prototype._redrawLabels = function () { DOMutil.prepareElements(this.DOMelements.lines); DOMutil.prepareElements(this.DOMelements.labels); var orientation = this.options['orientation']; + var customRange = this.options[orientation].range != undefined? this.options[orientation].range:{}; - // get the range for the slaved axis - var step; - if (this.master === false) { - var stepSize, rangeStart, rangeEnd, minimumStep; - if (this.zeroCrossing !== -1 && this.options.alignZeros === true) { - if (this.range.end > 0) { - stepSize = this.range.end / this.zeroCrossing; // size of one step - rangeStart = this.range.end - this.amountOfSteps * stepSize; - rangeEnd = this.range.end; - } - else { - // all of the range (including start) has to be done before the zero crossing. - stepSize = -1 * this.range.start / (this.amountOfSteps - this.zeroCrossing); // absolute size of a step - rangeStart = this.range.start; - rangeEnd = this.range.start + stepSize * this.amountOfSteps; - } - } - else { - rangeStart = this.range.start; - rangeEnd = this.range.end; - } - minimumStep = this.stepPixels; + //Override range with manual options: + var autoScaleEnd = true; + if (customRange.max != undefined){ + this.range.end = customRange.max; + autoScaleEnd = false; } - else { - // calculate range and step (step such that we have space for 7 characters per label) - minimumStep = this.props.majorCharHeight; - rangeStart = this.range.start; - rangeEnd = this.range.end; + var autoScaleStart = true; + if (customRange.min != undefined){ + this.range.start = customRange.min; + autoScaleStart = false; } - this.step = step = new DataStep( - rangeStart, - rangeEnd, - minimumStep, + this.scale = new DataScale( + this.range.start, + this.range.end, + autoScaleStart, + autoScaleEnd, this.dom.frame.offsetHeight, - this.options[this.options.orientation].range, - this.options[this.options.orientation].format, - this.master === false && this.options.alignZeros // does the step have to align zeros? only if not master and the options is on + this.props.majorCharHeight, + this.options.alignZeros, + this.options[orientation].format ); - // the slave axis needs to use the same horizontal lines as the master axis. - if (this.master === true) { - this.stepPixels = ((this.dom.frame.offsetHeight) / step.marginRange) * step.step; - this.amountOfSteps = Math.ceil(this.dom.frame.offsetHeight / this.stepPixels); - } - else { - // align with zero - if (this.options.alignZeros === true && this.zeroCrossing !== -1) { - // distance is the amount of steps away from the zero crossing we are. - let distance = (step.current - this.zeroCrossing * step.step) / step.step; - this.step.shift(distance); - } + if (this.master === false && this.masterAxis != undefined){ + this.scale.followScale(this.masterAxis.scale); } - // value at the bottom of the SVG - this.valueAtBottom = step.marginEnd; - - + //Is updated in side-effect of _redrawLabel(): this.maxLabelSize = 0; - var y = 0; // init value - var stepIndex = 0; // init value - var isMajor = false; // init value - while (stepIndex < this.amountOfSteps) { - y = Math.round(stepIndex * this.stepPixels); - isMajor = step.isMajor(); - if (stepIndex > 0 && stepIndex !== this.amountOfSteps) { - if (this.options['showMinorLabels'] && isMajor === false || this.master === false && this.options['showMinorLabels'] === true) { - this._redrawLabel(y - 2, step.getCurrent(), orientation, 'vis-y-axis vis-minor', this.props.minorCharHeight); + var lines = this.scale.getLines(); + lines.forEach( + line=> { + var y = line.y; + var isMajor = line.major; + if (this.options['showMinorLabels'] && isMajor === false) { + this._redrawLabel(y - 2, line.val, orientation, 'vis-y-axis vis-minor', this.props.minorCharHeight); } - - if (isMajor && this.options['showMajorLabels'] && this.master === true || - this.options['showMinorLabels'] === false && this.master === false && isMajor === true) { + if (isMajor) { if (y >= 0) { - this._redrawLabel(y - 2, step.getCurrent(), orientation, 'vis-y-axis vis-major', this.props.majorCharHeight); + this._redrawLabel(y - 2, line.val, orientation, 'vis-y-axis vis-major', this.props.majorCharHeight); } - this._redrawLine(y, orientation, 'vis-grid vis-horizontal vis-major', this.options.majorLinesOffset, this.props.majorLineWidth); } - else { - this._redrawLine(y, orientation, 'vis-grid vis-horizontal vis-minor', this.options.minorLinesOffset, this.props.minorLineWidth); + if (this.master === true) { + if (isMajor) { + this._redrawLine(y, orientation, 'vis-grid vis-horizontal vis-major', this.options.majorLinesOffset, this.props.majorLineWidth); + } + else { + this._redrawLine(y, orientation, 'vis-grid vis-horizontal vis-minor', this.options.minorLinesOffset, this.props.minorLineWidth); + } } - } - - // get zero crossing - if (this.master === true && step.current === 0) { - this.zeroCrossing = stepIndex; - } - - step.next(); - stepIndex += 1; - } - - // get zero crossing if it's the last step - if (this.master === true && step.current === 0) { - this.zeroCrossing = stepIndex; - } - - this.conversionFactor = this.stepPixels / step.step; + }); // Note that title is rotated, so we're using the height, not width! var titleWidth = 0; @@ -483,13 +435,11 @@ DataAxis.prototype._redrawLabels = function () { }; DataAxis.prototype.convertValue = function (value) { - var invertedValue = this.valueAtBottom - value; - var convertedValue = invertedValue * this.conversionFactor; - return convertedValue; + return this.scale.convertValue(value); }; DataAxis.prototype.screenToValue = function (x) { - return this.valueAtBottom - (x / this.conversionFactor); + return this.scale.screenToValue(x); }; /** diff --git a/lib/timeline/component/DataScale.js b/lib/timeline/component/DataScale.js new file mode 100644 index 000000000..5065f0e31 --- /dev/null +++ b/lib/timeline/component/DataScale.js @@ -0,0 +1,235 @@ +/** + * Created by ludo on 25-1-16. + */ + +function DataScale(start, end, autoScaleStart, autoScaleEnd, containerHeight, majorCharHeight, zeroAlign = false, formattingFunction=false) { + this.majorSteps = [1, 2, 5, 10]; + this.minorSteps = [0.25, 0.5, 1, 2]; + this.customLines = null; + + this.containerHeight = containerHeight; + this.majorCharHeight = majorCharHeight; + this._start = start; + this._end = end; + + this.scale = 1; + this.minorStepIdx = -1; + this.magnitudefactor = 1; + this.determineScale(); + + this.zeroAlign = zeroAlign; + this.autoScaleStart = autoScaleStart; + this.autoScaleEnd = autoScaleEnd; + + this.formattingFunction = formattingFunction; + + if (autoScaleStart || autoScaleEnd) { + var me = this; + var roundToMinor = function (value) { + var rounded = value - (value % (me.magnitudefactor * me.minorSteps[me.minorStepIdx])); + if (value % (me.magnitudefactor * me.minorSteps[me.minorStepIdx]) > 0.5 * (me.magnitudefactor * me.minorSteps[me.minorStepIdx])) { + return rounded + (me.magnitudefactor * me.minorSteps[me.minorStepIdx]); + } + else { + return rounded; + } + }; + if (autoScaleStart) { + this._start -= this.magnitudefactor * 2 * this.minorSteps[this.minorStepIdx]; + this._start = roundToMinor(this._start); + } + + if (autoScaleEnd) { + this._end += this.magnitudefactor * this.minorSteps[this.minorStepIdx]; + this._end = roundToMinor(this._end); + } + this.determineScale(); + } +} + +DataScale.prototype.setCharHeight = function (majorCharHeight) { + this.majorCharHeight = majorCharHeight; +}; + +DataScale.prototype.setHeight = function (containerHeight) { + this.containerHeight = containerHeight; +}; + +DataScale.prototype.determineScale = function () { + var range = this._end - this._start; + this.scale = this.containerHeight / range; + var minimumStepValue = this.majorCharHeight / this.scale; + var orderOfMagnitude = Math.round(Math.log(range) / Math.LN10); + + this.minorStepIdx = -1; + this.magnitudefactor = Math.pow(10, orderOfMagnitude); + + var start = 0; + if (orderOfMagnitude < 0) { + start = orderOfMagnitude; + } + + var solutionFound = false; + for (var l = start; Math.abs(l) <= Math.abs(orderOfMagnitude); l++) { + this.magnitudefactor = Math.pow(10, l); + for (var j = 0; j < this.minorSteps.length; j++) { + var stepSize = this.magnitudefactor * this.minorSteps[j]; + if (stepSize >= minimumStepValue) { + solutionFound = true; + this.minorStepIdx = j; + break; + } + } + if (solutionFound === true) { + break; + } + } +}; + +DataScale.prototype.is_major = function (value) { + return (value % (this.magnitudefactor * this.majorSteps[this.minorStepIdx]) === 0); +}; + +DataScale.prototype.getStep = function(){ + return this.magnitudefactor * this.minorSteps[this.minorStepIdx]; +}; + +DataScale.prototype.getFirstMajor = function(){ + var majorStep = this.magnitudefactor * this.majorSteps[this.minorStepIdx]; + return this.convertValue(this._start + ((majorStep - (this._start % majorStep)) % majorStep)); +}; + +DataScale.prototype.formatValue = function(current) { + var returnValue = current.toPrecision(5); + if (typeof this.formattingFunction === 'function') { + returnValue = this.formattingFunction(current); + } + + if (typeof returnValue === 'number') { + return '' + returnValue; + } + else if (typeof returnValue === 'string') { + return returnValue; + } + else { + return current.toPrecision(5); + } + +}; + +DataScale.prototype.getLines = function () { + var lines = []; + var step = this.getStep(); + var bottomOffset = (step - (this._start % step)) % step; + for (var i = (this._start + bottomOffset); this._end-i > 0.00001; i += step) { + if (i != this._start) { //Skip the bottom line + lines.push({major: this.is_major(i), y: this.convertValue(i), val: this.formatValue(i)}); + } + } + return lines; +}; + +DataScale.prototype.followScale = function (other) { + var oldStepIdx = this.minorStepIdx; + var oldStart = this._start; + var oldEnd = this._end; + + var me = this; + var increaseMagnitude = function () { + me.magnitudefactor *= 2; + }; + var decreaseMagnitude = function () { + me.magnitudefactor /= 2; + }; + + if ((other.minorStepIdx <= 1 && this.minorStepIdx <= 1) || (other.minorStepIdx > 1 && this.minorStepIdx > 1)) { + //easy, no need to change stepIdx nor multiplication factor + } else if (other.minorStepIdx < this.minorStepIdx) { + //I'm 5, they are 4 per major. + this.minorStepIdx = 1; + if (oldStepIdx == 2) { + increaseMagnitude(); + } else { + increaseMagnitude(); + increaseMagnitude(); + } + } else { + //I'm 4, they are 5 per major + this.minorStepIdx = 2; + if (oldStepIdx == 1) { + decreaseMagnitude(); + } else { + decreaseMagnitude(); + decreaseMagnitude(); + } + } + + //Get masters stats: + var lines = other.getLines(); + var otherZero = other.convertValue(0); + var otherStep = other.getStep() * other.scale; + + var done = false; + var count = 0; + //Loop until magnitude is correct for given constrains. + while (!done && count++ <5) { + + //Get my stats: + this.scale = otherStep / (this.minorSteps[this.minorStepIdx] * this.magnitudefactor); + var newRange = this.containerHeight / this.scale; + + //For the case the magnitudefactor has changed: + this._start = oldStart; + this._end = this._start + newRange; + + var myOriginalZero = this._end * this.scale; + var majorStep = this.magnitudefactor * this.majorSteps[this.minorStepIdx]; + var majorOffset = this.getFirstMajor() - other.getFirstMajor(); + + if (this.zeroAlign) { + var zeroOffset = otherZero - myOriginalZero; + this._end += (zeroOffset / this.scale); + this._start = this._end - newRange; + } else { + if (!this.autoScaleStart) { + this._start += majorStep - (majorOffset / this.scale); + this._end = this._start + newRange; + } else { + this._start -= majorOffset / this.scale; + this._end = this._start + newRange; + } + } + if (!this.autoScaleEnd && this._end > oldEnd+0.00001) { + //Need to decrease magnitude to prevent scale overshoot! (end) + decreaseMagnitude(); + done = false; + continue; + } + if (!this.autoScaleStart && this._start < oldStart-0.00001) { + if (this.zeroAlign && oldStart >= 0) { + console.warn("Can't adhere to given 'min' range, due to zeroalign"); + } else { + //Need to decrease magnitude to prevent scale overshoot! (start) + decreaseMagnitude(); + done = false; + continue; + } + } + if (this.autoScaleStart && this.autoScaleEnd && newRange < (oldEnd-oldStart)){ + increaseMagnitude(); + done = false; + continue; + } + done = true; + } +}; + +DataScale.prototype.convertValue = function (value) { + return this.containerHeight - ((value - this._start) * this.scale); +}; + +DataScale.prototype.screenToValue = function (pixels) { + return ((this.containerHeight - pixels) / this.scale) + this._start; +}; + +module.exports = DataScale; \ No newline at end of file diff --git a/lib/timeline/component/GraphGroup.js b/lib/timeline/component/GraphGroup.js index 0aac3316c..20f52e9f0 100644 --- a/lib/timeline/component/GraphGroup.js +++ b/lib/timeline/component/GraphGroup.js @@ -1,7 +1,7 @@ var util = require('../../util'); var DOMutil = require('../../DOMutil'); -var Line = require('./graph2d_types/line'); -var Bar = require('./graph2d_types/bar'); +var Bars = require('./graph2d_types/bar'); +var Lines = require('./graph2d_types/line'); var Points = require('./graph2d_types/points'); /** @@ -14,10 +14,10 @@ var Points = require('./graph2d_types/points'); * It enumerates through the default styles * @constructor */ -function GraphGroup (group, groupId, options, groupsUsingDefaultStyles) { +function GraphGroup(group, groupId, options, groupsUsingDefaultStyles) { this.id = groupId; - var fields = ['sampling','style','sort','yAxisOrientation','barChart','drawPoints','shaded','interpolation'] - this.options = util.selectiveBridgeObject(fields,options); + var fields = ['sampling', 'style', 'sort', 'yAxisOrientation', 'barChart', 'drawPoints', 'shaded', 'interpolation', 'zIndex','excludeFromStacking', 'excludeFromLegend']; + this.options = util.selectiveBridgeObject(fields, options); this.usingDefaultStyle = group.className === undefined; this.groupsUsingDefaultStyles = groupsUsingDefaultStyles; this.zeroPosition = 0; @@ -29,20 +29,17 @@ function GraphGroup (group, groupId, options, groupsUsingDefaultStyles) { this.visible = group.visible === undefined ? true : group.visible; } - /** * this loads a reference to all items in this group into this group. * @param {array} items */ -GraphGroup.prototype.setItems = function(items) { +GraphGroup.prototype.setItems = function (items) { if (items != null) { this.itemsData = items; if (this.options.sort == true) { - this.itemsData.sort(function (a,b) {return a.x - b.x;}) - } - // typecast all items to numbers. Takes around 10ms for 500.000 items - for (var i = 0; i < this.itemsData.length; i++) { - this.itemsData[i].y = Number(this.itemsData[i].y); + util.insertSort(this.itemsData,function (a, b) { + return a.x > b.x ? 1 : -1; + }); } } else { @@ -50,35 +47,37 @@ GraphGroup.prototype.setItems = function(items) { } }; +GraphGroup.prototype.getItems = function () { + return this.itemsData; +} /** - * this is used for plotting barcharts, this way, we only have to calculate it once. + * this is used for barcharts and shading, this way, we only have to calculate it once. * @param pos */ -GraphGroup.prototype.setZeroPosition = function(pos) { +GraphGroup.prototype.setZeroPosition = function (pos) { this.zeroPosition = pos; }; - /** * set the options of the graph group over the default options. * @param options */ -GraphGroup.prototype.setOptions = function(options) { +GraphGroup.prototype.setOptions = function (options) { if (options !== undefined) { - var fields = ['sampling','style','sort','yAxisOrientation','barChart']; + var fields = ['sampling', 'style', 'sort', 'yAxisOrientation', 'barChart', 'zIndex','excludeFromStacking', 'excludeFromLegend']; util.selectiveDeepExtend(fields, this.options, options); // if the group's drawPoints is a function delegate the callback to the onRender property if (typeof options.drawPoints == 'function') { - options.drawPoints = { - onRender: options.drawPoints - } + options.drawPoints = { + onRender: options.drawPoints + } } - - util.mergeOptions(this.options, options,'interpolation'); - util.mergeOptions(this.options, options,'drawPoints'); - util.mergeOptions(this.options, options,'shaded'); + + util.mergeOptions(this.options, options, 'interpolation'); + util.mergeOptions(this.options, options, 'drawPoints'); + util.mergeOptions(this.options, options, 'shaded'); if (options.interpolation) { if (typeof options.interpolation == 'object') { @@ -97,16 +96,6 @@ GraphGroup.prototype.setOptions = function(options) { } } } - - if (this.options.style == 'line') { - this.type = new Line(this.id, this.options); - } - else if (this.options.style == 'bar') { - this.type = new Bar(this.id, this.options); - } - else if (this.options.style == 'points') { - this.type = new Points(this.id, this.options); - } }; @@ -114,7 +103,7 @@ GraphGroup.prototype.setOptions = function(options) { * this updates the current group class with the latest group dataset entree, used in _updateGroup in linegraph * @param group */ -GraphGroup.prototype.update = function(group) { +GraphGroup.prototype.update = function (group) { this.group = group; this.content = group.content || 'graph'; this.className = group.className || this.className || 'vis-graph-group' + this.groupsUsingDefaultStyles[0] % 10; @@ -123,73 +112,6 @@ GraphGroup.prototype.update = function(group) { this.setOptions(group.options); }; - -/** - * draw the icon for the legend. - * - * @param x - * @param y - * @param JSONcontainer - * @param SVGcontainer - * @param iconWidth - * @param iconHeight - */ -GraphGroup.prototype.drawIcon = function(x, y, JSONcontainer, SVGcontainer, iconWidth, iconHeight) { - var fillHeight = iconHeight * 0.5; - var path, fillPath; - - var outline = DOMutil.getSVGElement("rect", JSONcontainer, SVGcontainer); - outline.setAttributeNS(null, "x", x); - outline.setAttributeNS(null, "y", y - fillHeight); - outline.setAttributeNS(null, "width", iconWidth); - outline.setAttributeNS(null, "height", 2*fillHeight); - outline.setAttributeNS(null, "class", "vis-outline"); - - if (this.options.style == 'line') { - path = DOMutil.getSVGElement("path", JSONcontainer, SVGcontainer); - path.setAttributeNS(null, "class", this.className); - if(this.style !== undefined) { - path.setAttributeNS(null, "style", this.style); - } - - path.setAttributeNS(null, "d", "M" + x + ","+y+" L" + (x + iconWidth) + ","+y+""); - if (this.options.shaded.enabled == true) { - fillPath = DOMutil.getSVGElement("path", JSONcontainer, SVGcontainer); - if (this.options.shaded.orientation == 'top') { - fillPath.setAttributeNS(null, "d", "M"+x+", " + (y - fillHeight) + - "L"+x+","+y+" L"+ (x + iconWidth) + ","+y+" L"+ (x + iconWidth) + "," + (y - fillHeight)); - } - else { - fillPath.setAttributeNS(null, "d", "M"+x+","+y+" " + - "L"+x+"," + (y + fillHeight) + " " + - "L"+ (x + iconWidth) + "," + (y + fillHeight) + - "L"+ (x + iconWidth) + ","+y); - } - fillPath.setAttributeNS(null, "class", this.className + " vis-icon-fill"); - } - - if (this.options.drawPoints.enabled == true) { - var groupTemplate = { - style: this.options.drawPoints.style, - size:this.options.drawPoints.size, - className: this.className - }; - DOMutil.drawPoint(x + 0.5 * iconWidth, y, groupTemplate, JSONcontainer, SVGcontainer); - } - } - else { - var barWidth = Math.round(0.3 * iconWidth); - var bar1Height = Math.round(0.4 * iconHeight); - var bar2Height = Math.round(0.75 * iconHeight); - - var offset = Math.round((iconWidth - (2 * barWidth))/3); - - DOMutil.drawBar(x + 0.5*barWidth + offset , y + fillHeight - bar1Height - 1, barWidth, bar1Height, this.className + ' vis-bar', JSONcontainer, SVGcontainer, this.style); - DOMutil.drawBar(x + 1.5*barWidth + offset + 2, y + fillHeight - bar2Height - 1, barWidth, bar2Height, this.className + ' vis-bar', JSONcontainer, SVGcontainer, this.style); - } -}; - - /** * return the legend entree for this group. * @@ -197,23 +119,40 @@ GraphGroup.prototype.drawIcon = function(x, y, JSONcontainer, SVGcontainer, icon * @param iconHeight * @returns {{icon: HTMLElement, label: (group.content|*|string), orientation: (.options.yAxisOrientation|*)}} */ -GraphGroup.prototype.getLegend = function(iconWidth, iconHeight) { - var svg = document.createElementNS('http://www.w3.org/2000/svg',"svg"); - this.drawIcon(0,0.5*iconHeight,[],svg,iconWidth,iconHeight); - return {icon: svg, label: this.content, orientation:this.options.yAxisOrientation}; +GraphGroup.prototype.getLegend = function (iconWidth, iconHeight, framework, x, y) { + if (framework == undefined || framework == null) { + var svg = document.createElementNS('http://www.w3.org/2000/svg', "svg"); + framework = {svg: svg, svgElements:{}, options: this.options, groups: [this]} + } + if (x == undefined || x == null){ + x = 0; + } + if (y == undefined || y == null){ + y = 0.5 * iconHeight; + } + switch (this.options.style){ + case "line": + Lines.drawIcon(this, x, y, iconWidth, iconHeight, framework); + break; + case "points": //explicit no break + case "point": + Points.drawIcon(this, x, y, iconWidth, iconHeight, framework); + break; + case "bar": + Bars.drawIcon(this, x, y, iconWidth, iconHeight, framework); + break; + } + return {icon: framework.svg, label: this.content, orientation: this.options.yAxisOrientation}; }; -GraphGroup.prototype.getYRange = function(groupData) { - return this.type.getYRange(groupData); +GraphGroup.prototype.getYRange = function (groupData) { + var yMin = groupData[0].y; + var yMax = groupData[0].y; + for (var j = 0; j < groupData.length; j++) { + yMin = yMin > groupData[j].y ? groupData[j].y : yMin; + yMax = yMax < groupData[j].y ? groupData[j].y : yMax; + } + return {min: yMin, max: yMax, yAxisOrientation: this.options.yAxisOrientation}; }; -GraphGroup.prototype.getData = function(groupData) { - return this.type.getData(groupData); -}; - -GraphGroup.prototype.draw = function(dataset, group, framework) { - this.type.draw(dataset, group, framework); -}; - - module.exports = GraphGroup; diff --git a/lib/timeline/component/Group.js b/lib/timeline/component/Group.js index ae60270a9..8a09e4aec 100644 --- a/lib/timeline/component/Group.js +++ b/lib/timeline/component/Group.js @@ -47,7 +47,11 @@ function Group (groupId, data, itemSet) { */ Group.prototype._create = function() { var label = document.createElement('div'); - label.className = 'vis-label'; + if (this.itemSet.options.groupEditable.order) { + label.className = 'vis-label draggable'; + } else { + label.className = 'vis-label'; + } this.dom.label = label; var inner = document.createElement('div'); @@ -173,6 +177,9 @@ Group.prototype.redraw = function(range, margin, restack) { restack = true; } + // recalculate the height of the subgroups + this._calculateSubGroupHeights(); + // reposition visible items vertically if (typeof this.itemSet.options.order === 'function') { // a custom order function @@ -240,6 +247,25 @@ Group.prototype.redraw = function(range, margin, restack) { return resized; }; +/** + * recalculate the height of the subgroups + * @private + */ +Group.prototype._calculateSubGroupHeights = function () { + if (Object.keys(this.subgroups).length > 0) { + var me = this; + + this.resetSubgroups(); + + util.forEach(this.visibleItems, function (item) { + if (item.data.subgroup !== undefined) { + me.subgroups[item.data.subgroup].height = Math.max(me.subgroups[item.data.subgroup].height, item.height); + me.subgroups[item.data.subgroup].visible = true; + } + }); + } +}; + /** * recalculate the height of the group * @param {{item: {horizontal: number, vertical: number}, axis: number}} margin @@ -250,20 +276,12 @@ Group.prototype._calculateHeight = function (margin) { // recalculate the height of the group var height; var visibleItems = this.visibleItems; - //var visibleSubgroups = []; - //this.visibleSubgroups = 0; - this.resetSubgroups(); - var me = this; if (visibleItems.length > 0) { var min = visibleItems[0].top; var max = visibleItems[0].top + visibleItems[0].height; util.forEach(visibleItems, function (item) { min = Math.min(min, item.top); max = Math.max(max, (item.top + item.height)); - if (item.data.subgroup !== undefined) { - me.subgroups[item.data.subgroup].height = Math.max(me.subgroups[item.data.subgroup].height,item.height); - me.subgroups[item.data.subgroup].visible = true; - } }); if (min > margin.axis) { // there is an empty gap between the lowest item and the axis diff --git a/lib/timeline/component/ItemSet.js b/lib/timeline/component/ItemSet.js index b6ac97abd..d66c77e46 100644 --- a/lib/timeline/component/ItemSet.js +++ b/lib/timeline/component/ItemSet.js @@ -34,10 +34,16 @@ function ItemSet(body, options) { }, align: 'auto', // alignment of box items stack: true, - groupOrder: null, + groupOrderSwap: function(fromGroup, toGroup, groups) { + var targetOrder = toGroup.order; + toGroup.order = fromGroup.order; + fromGroup.order = targetOrder; + }, + groupOrder: 'order', selectable: true, multiselect: false, + itemsAlwaysDraggable: false, editable: { updateTime: false, @@ -46,6 +52,12 @@ function ItemSet(body, options) { remove: false }, + groupEditable: { + order: false, + add: false, + remove: false + }, + snap: TimeStep.snap, onAdd: function (item, callback) { @@ -63,6 +75,15 @@ function ItemSet(body, options) { onMoving: function (item, callback) { callback(item); }, + onAddGroup: function (item, callback) { + callback(item); + }, + onMoveGroup: function (item, callback) { + callback(item); + }, + onRemoveGroup: function (item, callback) { + callback(item); + }, margin: { item: { @@ -127,6 +148,7 @@ function ItemSet(body, options) { this.stackDirty = true; // if true, all items will be restacked on next redraw this.touchParams = {}; // stores properties while dragging + this.groupTouchParams = {}; // create the HTML DOM this._create(); @@ -209,6 +231,12 @@ ItemSet.prototype._create = function(){ // add item on doubletap this.hammer.on('doubletap', this._onAddItem.bind(this)); + this.groupHammer = new Hammer(this.body.dom.leftContainer); + this.groupHammer.on('panstart', this._onGroupDragStart.bind(this)); + this.groupHammer.on('panmove', this._onGroupDrag.bind(this)); + this.groupHammer.on('panend', this._onGroupDragEnd.bind(this)); + this.groupHammer.get('pan').set({threshold:5, direction:30}); + // attach to the DOM this.show(); }; @@ -280,7 +308,7 @@ ItemSet.prototype._create = function(){ ItemSet.prototype.setOptions = function(options) { if (options) { // copy all options that we know - var fields = ['type', 'align', 'order', 'stack', 'selectable', 'multiselect', 'groupOrder', 'dataAttributes', 'template','groupTemplate','hide', 'snap']; + var fields = ['type', 'align', 'order', 'stack', 'selectable', 'multiselect', 'itemsAlwaysDraggable', 'multiselectPerGroup', 'groupOrder', 'dataAttributes', 'template', 'groupTemplate', 'hide', 'snap', 'groupOrderSwap']; util.selectiveExtend(fields, this.options, options); if ('orientation' in options) { @@ -323,6 +351,17 @@ ItemSet.prototype.setOptions = function(options) { util.selectiveExtend(['updateTime', 'updateGroup', 'add', 'remove'], this.options.editable, options.editable); } } + + if ('groupEditable' in options) { + if (typeof options.groupEditable === 'boolean') { + this.options.groupEditable.order = options.groupEditable; + this.options.groupEditable.add = options.groupEditable; + this.options.groupEditable.remove = options.groupEditable; + } + else if (typeof options.groupEditable === 'object') { + util.selectiveExtend(['order', 'add', 'remove'], this.options.groupEditable, options.groupEditable); + } + } // callback functions var addCallback = (function (name) { @@ -334,7 +373,7 @@ ItemSet.prototype.setOptions = function(options) { this.options[name] = fn; } }).bind(this); - ['onAdd', 'onUpdate', 'onRemove', 'onMove', 'onMoving'].forEach(addCallback); + ['onAdd', 'onUpdate', 'onRemove', 'onMove', 'onMoving', 'onAddGroup', 'onMoveGroup', 'onRemoveGroup'].forEach(addCallback); // force the itemSet to refresh: options like orientation and margins may be changed this.markDirty(); @@ -687,6 +726,8 @@ ItemSet.prototype.setItems = function(items) { // update the group holding all ungrouped items this._updateUngrouped(); } + + this.body.emitter.emit('_change', {queue: true}); }; /** @@ -746,7 +787,7 @@ ItemSet.prototype.setGroups = function(groups) { // update the order of all items in each group this._order(); - this.body.emitter.emit('change', {queue: true}); + this.body.emitter.emit('_change', {queue: true}); }; /** @@ -857,7 +898,7 @@ ItemSet.prototype._onUpdate = function(ids) { this._order(); this.stackDirty = true; // force re-stacking of all items next redraw - this.body.emitter.emit('change', {queue: true}); + this.body.emitter.emit('_change', {queue: true}); }; /** @@ -887,7 +928,7 @@ ItemSet.prototype._onRemove = function(ids) { // update order this._order(); this.stackDirty = true; // force re-stacking of all items next redraw - this.body.emitter.emit('change', {queue: true}); + this.body.emitter.emit('_change', {queue: true}); } }; @@ -957,7 +998,7 @@ ItemSet.prototype._onAddGroups = function(ids) { } }); - this.body.emitter.emit('change', {queue: true}); + this.body.emitter.emit('_change', {queue: true}); }; /** @@ -978,7 +1019,7 @@ ItemSet.prototype._onRemoveGroups = function(ids) { this.markDirty(); - this.body.emitter.emit('change', {queue: true}); + this.body.emitter.emit('_change', {queue: true}); }; /** @@ -1110,6 +1151,20 @@ ItemSet.prototype._onTouch = function (event) { this.touchParams.itemProps = null; }; + +/** + * Given an group id, returns the index it has. + * + * @param {Number} groupID + * @private + */ +ItemSet.prototype._getGroupIndex = function(groupId) { + for (var i = 0; i < this.groupIds.length; i++) { + if (groupId == this.groupIds[i]) + return i; + } +}; + /** * Start dragging the selected events * @param {Event} event @@ -1120,10 +1175,10 @@ ItemSet.prototype._onDragStart = function (event) { var me = this; var props; - if (item && item.selected) { + if (item && (item.selected || this.options.itemsAlwaysDraggable)) { - if (!this.options.editable.updateTime && - !this.options.editable.updateGroup && + if (!this.options.editable.updateTime && + !this.options.editable.updateGroup && !item.editable) { return; } @@ -1141,7 +1196,7 @@ ItemSet.prototype._onDragStart = function (event) { item: dragLeftItem, initialX: event.center.x, dragLeft: true, - data: util.extend({}, item.data) // clone the items data + data: this._cloneItemData(item.data) }; this.touchParams.itemProps = [props]; @@ -1151,22 +1206,28 @@ ItemSet.prototype._onDragStart = function (event) { item: dragRightItem, initialX: event.center.x, dragRight: true, - data: util.extend({}, item.data) // clone the items data + data: this._cloneItemData(item.data) }; this.touchParams.itemProps = [props]; } else { - this.touchParams.itemProps = this.getSelection().map(function (id) { + this.touchParams.selectedItem = item; + + var baseGroupIndex = this._getGroupIndex(item.data.group); + + var itemsToDrag = (this.options.itemsAlwaysDraggable && !item.selected) ? [item.id] : this.getSelection(); + + this.touchParams.itemProps = itemsToDrag.map(function (id) { var item = me.items[id]; - var props = { + var groupIndex = me._getGroupIndex(item.data.group); + return { item: item, initialX: event.center.x, - data: util.extend({}, item.data) // clone the items data + groupOffset: baseGroupIndex-groupIndex, + data: this._cloneItemData(item.data) }; - - return props; - }); + }.bind(this)); } event.stopPropagation(); @@ -1189,7 +1250,7 @@ ItemSet.prototype._onDragStartAddItem = function (event) { var time = this.body.util.toTime(x); var scale = this.body.util.getScale(); var step = this.body.util.getStep(); - var start = snap ? snap(time, scale, step) : start; + var start = snap ? snap(time, scale, step) : time; var end = start; var itemData = { @@ -1209,14 +1270,14 @@ ItemSet.prototype._onDragStartAddItem = function (event) { var newItem = new RangeItem(itemData, this.conversion, this.options); newItem.id = id; // TODO: not so nice setting id afterwards - newItem.data = itemData; + newItem.data = this._cloneItemData(itemData); this._addItem(newItem); var props = { item: newItem, dragRight: true, initialX: event.center.x, - data: util.extend({}, itemData) + data: newItem.data }; this.touchParams.itemProps = [props]; @@ -1238,21 +1299,35 @@ ItemSet.prototype._onDrag = function (event) { var scale = this.body.util.getScale(); var step = this.body.util.getStep(); + //only calculate the new group for the item that's actually dragged + var selectedItem = this.touchParams.selectedItem; + var updateGroupAllowed = me.options.editable.updateGroup; + var newGroupBase = null; + if (updateGroupAllowed && selectedItem) { + if (selectedItem.data.group != undefined) { + // drag from one group to another + var group = me.groupFromTarget(event); + if (group) { + //we know the offset for all items, so the new group for all items + //will be relative to this one. + newGroupBase = this._getGroupIndex(group.groupId); + } + } + } + // move this.touchParams.itemProps.forEach(function (props) { - var newProps = {}; var current = me.body.util.toTime(event.center.x - xOffset); var initial = me.body.util.toTime(props.initialX - xOffset); - var offset = current - initial; - - var itemData = util.extend({}, props.item.data); // clone the data + var offset = current - initial; // ms + var itemData = this._cloneItemData(props.item.data); // clone the data if (props.item.editable === false) { return; } - var updateTimeAllowed = me.options.editable.updateTime || - props.item.editable === true; + var updateTimeAllowed = me.options.editable.updateTime || + props.item.editable === true; if (updateTimeAllowed) { if (props.dragLeft) { @@ -1260,6 +1335,7 @@ ItemSet.prototype._onDrag = function (event) { if (itemData.start != undefined) { var initialStart = util.convert(props.data.start, 'Date'); var start = new Date(initialStart.valueOf() + offset); + // TODO: pass a Moment instead of a Date to snap(). (Breaking change) itemData.start = snap ? snap(start, scale, step) : start; } } @@ -1268,6 +1344,7 @@ ItemSet.prototype._onDrag = function (event) { if (itemData.end != undefined) { var initialEnd = util.convert(props.data.end, 'Date'); var end = new Date(initialEnd.valueOf() + offset); + // TODO: pass a Moment instead of a Date to snap(). (Breaking change) itemData.end = snap ? snap(end, scale, step) : end; } } @@ -1281,39 +1358,44 @@ ItemSet.prototype._onDrag = function (event) { var initialEnd = util.convert(props.data.end, 'Date'); var duration = initialEnd.valueOf() - initialStart.valueOf(); + // TODO: pass a Moment instead of a Date to snap(). (Breaking change) itemData.start = snap ? snap(start, scale, step) : start; itemData.end = new Date(itemData.start.valueOf() + duration); } else { + // TODO: pass a Moment instead of a Date to snap(). (Breaking change) itemData.start = snap ? snap(start, scale, step) : start; } } } } - var updateGroupAllowed = me.options.editable.updateGroup || - props.item.editable === true; + var updateGroupAllowed = me.options.editable.updateGroup || + props.item.editable === true; - if (updateGroupAllowed && (!props.dragLeft && !props.dragRight)) { + if (updateGroupAllowed && (!props.dragLeft && !props.dragRight) && newGroupBase!=null) { if (itemData.group != undefined) { - // drag from one group to another - var group = me.groupFromTarget(event); - if (group) { - itemData.group = group.groupId; - } + var newOffset = newGroupBase - props.groupOffset; + + //make sure we stay in bounds + newOffset = Math.max(0, newOffset); + newOffset = Math.min(me.groupIds.length-1, newOffset); + + itemData.group = me.groupIds[newOffset]; } } // confirm moving the item + itemData = this._cloneItemData(itemData); // convert start and end to the correct type me.options.onMoving(itemData, function (itemData) { if (itemData) { - props.item.setData(itemData); + props.item.setData(this._cloneItemData(itemData, 'Date')); } - }); - }); + }.bind(this)); + }.bind(this)); this.stackDirty = true; // force re-stacking of all items next redraw - this.body.emitter.emit('change'); + this.body.emitter.emit('_change'); } }; @@ -1364,12 +1446,12 @@ ItemSet.prototype._onDragEnd = function (event) { // force re-stacking of all items next redraw me.stackDirty = true; - me.body.emitter.emit('change'); + me.body.emitter.emit('_change'); }); } else { // update existing item - var itemData = util.extend({}, props.item.data); // clone the data + var itemData = this._cloneItemData(props.item.data); // convert start and end to the correct type me.options.onMove(itemData, function (itemData) { if (itemData) { // apply changes @@ -1381,14 +1463,190 @@ ItemSet.prototype._onDragEnd = function (event) { props.item.setData(props.data); me.stackDirty = true; // force re-stacking of all items next redraw - me.body.emitter.emit('change'); + me.body.emitter.emit('_change'); } }); } - }); + }.bind(this)); } }; +ItemSet.prototype._onGroupDragStart = function (event) { + if (this.options.groupEditable.order) { + this.groupTouchParams.group = this.groupFromTarget(event); + + if (this.groupTouchParams.group) { + event.stopPropagation(); + + this.groupTouchParams.originalOrder = this.groupsData.getIds({ + order: this.options.groupOrder + }); + } + } +} + +ItemSet.prototype._onGroupDrag = function (event) { + if (this.options.groupEditable.order && this.groupTouchParams.group) { + event.stopPropagation(); + + // drag from one group to another + var group = this.groupFromTarget(event); + + // try to avoid toggling when groups differ in height + if (group && group.height != this.groupTouchParams.group.height) { + var movingUp = (group.top < this.groupTouchParams.group.top); + var clientY = event.center ? event.center.y : event.clientY; + var targetGroupTop = util.getAbsoluteTop(group.dom.foreground); + var draggedGroupHeight = this.groupTouchParams.group.height; + if (movingUp) { + // skip swapping the groups when the dragged group is not below clientY afterwards + if (targetGroupTop + draggedGroupHeight < clientY) { + return; + } + } else { + var targetGroupHeight = group.height; + // skip swapping the groups when the dragged group is not below clientY afterwards + if (targetGroupTop + targetGroupHeight - draggedGroupHeight > clientY) { + return; + } + } + } + + if (group && group != this.groupTouchParams.group) { + var groupsData = this.groupsData; + var targetGroup = groupsData.get(group.groupId); + var draggedGroup = groupsData.get(this.groupTouchParams.group.groupId); + + // switch groups + if (draggedGroup && targetGroup) { + this.options.groupOrderSwap(draggedGroup, targetGroup, this.groupsData); + this.groupsData.update(draggedGroup); + this.groupsData.update(targetGroup); + } + + // fetch current order of groups + var newOrder = this.groupsData.getIds({ + order: this.options.groupOrder + }); + + // in case of changes since _onGroupDragStart + if (!util.equalArray(newOrder, this.groupTouchParams.originalOrder)) { + var groupsData = this.groupsData; + var origOrder = this.groupTouchParams.originalOrder; + var draggedId = this.groupTouchParams.group.groupId; + var numGroups = Math.min(origOrder.length, newOrder.length); + var curPos = 0; + var newOffset = 0; + var orgOffset = 0; + while (curPos < numGroups) { + // as long as the groups are where they should be step down along the groups order + while ((curPos+newOffset) < numGroups + && (curPos+orgOffset) < numGroups + && newOrder[curPos+newOffset] == origOrder[curPos+orgOffset]) { + curPos++; + } + + // all ok + if (curPos+newOffset >= numGroups) { + break; + } + + // not all ok + // if dragged group was move upwards everything below should have an offset + if (newOrder[curPos+newOffset] == draggedId) { + newOffset = 1; + continue; + } + // if dragged group was move downwards everything above should have an offset + else if (origOrder[curPos+orgOffset] == draggedId) { + orgOffset = 1; + continue; + } + // found a group (apart from dragged group) that has the wrong position -> switch with the + // group at the position where other one should be, fix index arrays and continue + else { + var slippedPosition = newOrder.indexOf(origOrder[curPos+orgOffset]) + var switchGroup = groupsData.get(newOrder[curPos+newOffset]); + var shouldBeGroup = groupsData.get(origOrder[curPos+orgOffset]); + this.options.groupOrderSwap(switchGroup, shouldBeGroup, groupsData); + groupsData.update(switchGroup); + groupsData.update(shouldBeGroup); + + var switchGroupId = newOrder[curPos+newOffset]; + newOrder[curPos+newOffset] = origOrder[curPos+orgOffset]; + newOrder[slippedPosition] = switchGroupId; + + curPos++; + } + } + } + + } + } +} + +ItemSet.prototype._onGroupDragEnd = function (event) { + if (this.options.groupEditable.order && this.groupTouchParams.group) { + event.stopPropagation(); + + // update existing group + var me = this; + var id = me.groupTouchParams.group.groupId; + var dataset = me.groupsData.getDataSet(); + var groupData = util.extend({}, dataset.get(id)); // clone the data + me.options.onMoveGroup(groupData, function (groupData) { + if (groupData) { + // apply changes + groupData[dataset._fieldId] = id; // ensure the group contains its id (can be undefined) + dataset.update(groupData); + } + else { + + // fetch current order of groups + var newOrder = dataset.getIds({ + order: me.options.groupOrder + }); + + // restore original order + if (!util.equalArray(newOrder, me.groupTouchParams.originalOrder)) { + var origOrder = me.groupTouchParams.originalOrder; + var numGroups = Math.min(origOrder.length, newOrder.length); + var curPos = 0; + while (curPos < numGroups) { + // as long as the groups are where they should be step down along the groups order + while (curPos < numGroups && newOrder[curPos] == origOrder[curPos]) { + curPos++; + } + + // all ok + if (curPos >= numGroups) { + break; + } + + // found a group that has the wrong position -> switch with the + // group at the position where other one should be, fix index arrays and continue + var slippedPosition = newOrder.indexOf(origOrder[curPos]) + var switchGroup = dataset.get(newOrder[curPos]); + var shouldBeGroup = dataset.get(origOrder[curPos]); + me.options.groupOrderSwap(switchGroup, shouldBeGroup, dataset); + groupsData.update(switchGroup); + groupsData.update(shouldBeGroup); + + var switchGroupId = newOrder[curPos]; + newOrder[curPos] = origOrder[curPos]; + newOrder[slippedPosition] = switchGroupId; + + curPos++; + } + } + + } + }); + + me.body.emitter.emit('groupDragged', { groupId: id }); + } +} + /** * Handle selecting/deselecting an item when tapping it * @param {Event} event @@ -1435,8 +1693,6 @@ ItemSet.prototype._onAddItem = function (event) { var snap = this.options.snap || null; var item = this.itemFromTarget(event); - event.stopPropagation(); - if (item) { // update item @@ -1456,7 +1712,7 @@ ItemSet.prototype._onAddItem = function (event) { var scale = this.body.util.getScale(); var step = this.body.util.getStep(); - var newItem = { + var newItemData = { start: snap ? snap(start, scale, step) : start, content: 'new item' }; @@ -1464,18 +1720,19 @@ ItemSet.prototype._onAddItem = function (event) { // when default type is a range, add a default end date to the new item if (this.options.type === 'range') { var end = this.body.util.toTime(x + this.props.width / 5); - newItem.end = snap ? snap(end, scale, step) : end; + newItemData.end = snap ? snap(end, scale, step) : end; } - newItem[this.itemsData._fieldId] = util.randomUUID(); + newItemData[this.itemsData._fieldId] = util.randomUUID(); var group = this.groupFromTarget(event); if (group) { - newItem.group = group.groupId; + newItemData.group = group.groupId; } // execute async handler to customize (or cancel) adding an item - this.options.onAdd(newItem, function (item) { + newItemData = this._cloneItemData(newItemData); // convert start and end to the correct type + this.options.onAdd(newItemData, function (item) { if (item) { me.itemsData.getDataSet().add(item); // TODO: need to trigger a redraw? @@ -1505,23 +1762,37 @@ ItemSet.prototype._onMultiSelectItem = function (event) { if (shiftKey && this.options.multiselect) { // select all items between the old selection and the tapped item - + var itemGroup = this.itemsData.get(item.id).group; + + // when filtering get the group of the last selected item + var lastSelectedGroup = undefined; + if (this.options.multiselectPerGroup) { + if (selection.length > 0) { + lastSelectedGroup = this.itemsData.get(selection[0]).group; + } + } + // determine the selection range - selection.push(item.id); + if (!this.options.multiselectPerGroup || lastSelectedGroup == undefined || lastSelectedGroup == itemGroup) { + selection.push(item.id); + } var range = ItemSet._getItemRange(this.itemsData.get(selection, this.itemOptions)); + + if (!this.options.multiselectPerGroup || lastSelectedGroup == itemGroup) { + // select all items within the selection range + selection = []; + for (var id in this.items) { + if (this.items.hasOwnProperty(id)) { + var _item = this.items[id]; + var start = _item.data.start; + var end = (_item.data.end !== undefined) ? _item.data.end : start; - // select all items within the selection range - selection = []; - for (var id in this.items) { - if (this.items.hasOwnProperty(id)) { - var _item = this.items[id]; - var start = _item.data.start; - var end = (_item.data.end !== undefined) ? _item.data.end : start; - - if (start >= range.min && - end <= range.max && - !(_item instanceof BackgroundItem)) { - selection.push(_item.id); // do not use id but item.id, id itself is stringified + if (start >= range.min && + end <= range.max && + (!this.options.multiselectPerGroup || lastSelectedGroup == this.itemsData.get(_item.id).group) && + !(_item instanceof BackgroundItem)) { + selection.push(_item.id); // do not use id but item.id, id itself is stringified + } } } } @@ -1649,4 +1920,31 @@ ItemSet.itemSetFromTarget = function(event) { return null; }; +/** + * Clone the data of an item, and "normalize" it: convert the start and end date + * to the type (Date, Moment, ...) configured in the DataSet. If not configured, + * start and end are converted to Date. + * @param {Object} itemData, typically `item.data` + * @param {string} [type] Optional Date type. If not provided, the type from the DataSet is taken + * @return {Object} The cloned object + * @private + */ +ItemSet.prototype._cloneItemData = function (itemData, type) { + var clone = util.extend({}, itemData); + + if (!type) { + // convert start and end date to the type (Date, Moment, ...) configured in the DataSet + type = this.itemsData.getDataSet()._options.type; + } + + if (clone.start != undefined) { + clone.start = util.convert(clone.start, type && type.start || 'Date'); + } + if (clone.end != undefined) { + clone.end = util.convert(clone.end , type && type.end || 'Date'); + } + + return clone; +}; + module.exports = ItemSet; diff --git a/lib/timeline/component/Legend.js b/lib/timeline/component/Legend.js index e34e9d951..90b972595 100644 --- a/lib/timeline/component/Legend.js +++ b/lib/timeline/component/Legend.js @@ -8,7 +8,7 @@ var Component = require('./Component'); function Legend(body, options, side, linegraphOptions) { this.body = body; this.defaultOptions = { - enabled: true, + enabled: false, icons: true, iconSize: 20, iconSpacing: 6, @@ -18,9 +18,10 @@ function Legend(body, options, side, linegraphOptions) { }, right: { visible: true, - position: 'top-left' // top/bottom - left,center,right + position: 'top-right' // top/bottom - left,center,right } } + this.side = side; this.options = util.extend({},this.defaultOptions); this.linegraphOptions = linegraphOptions; @@ -30,6 +31,7 @@ function Legend(body, options, side, linegraphOptions) { this.groups = {}; this.amountOfGroups = 0; this._create(); + this.framework = {svg: this.svg, svgElements: this.svgElements, options: this.options, groups: this.groups}; this.setOptions(options); } @@ -43,10 +45,13 @@ Legend.prototype.clear = function() { Legend.prototype.addGroup = function(label, graphOptions) { - if (!this.groups.hasOwnProperty(label)) { - this.groups[label] = graphOptions; + // Include a group only if the group option 'excludeFromLegend: false' is not set. + if (graphOptions.options.excludeFromLegend != true) { + if (!this.groups.hasOwnProperty(label)) { + this.groups[label] = graphOptions; + } + this.amountOfGroups += 1; } - this.amountOfGroups += 1; }; Legend.prototype.updateGroup = function(label, graphOptions) { @@ -184,9 +189,11 @@ Legend.prototype.drawLegendIcons = function() { var groupArray = Object.keys(this.groups); groupArray.sort(function (a,b) { return (a < b ? -1 : 1); - }) + }); + + // this resets the elements so the order is maintained + DOMutil.resetElements(this.svgElements); - DOMutil.prepareElements(this.svgElements); var padding = window.getComputedStyle(this.dom.frame).paddingTop; var iconOffset = Number(padding.replace('px','')); var x = iconOffset; @@ -199,12 +206,10 @@ Legend.prototype.drawLegendIcons = function() { for (var i = 0; i < groupArray.length; i++) { var groupId = groupArray[i]; if (this.groups[groupId].visible == true && (this.linegraphOptions.visibility[groupId] === undefined || this.linegraphOptions.visibility[groupId] == true)) { - this.groups[groupId].drawIcon(x, y, this.svgElements, this.svg, iconWidth, iconHeight); + this.groups[groupId].getLegend(iconWidth, iconHeight, this.framework, x, y); y += iconHeight + this.options.iconSpacing; } } - - DOMutil.cleanupElements(this.svgElements); } }; diff --git a/lib/timeline/component/LineGraph.js b/lib/timeline/component/LineGraph.js index f612ff527..961a293dc 100644 --- a/lib/timeline/component/LineGraph.js +++ b/lib/timeline/component/LineGraph.js @@ -6,8 +6,9 @@ var Component = require('./Component'); var DataAxis = require('./DataAxis'); var GraphGroup = require('./GraphGroup'); var Legend = require('./Legend'); -var BarFunctions = require('./graph2d_types/bar'); -var LineFunctions = require('./graph2d_types/line'); +var Bars = require('./graph2d_types/bar'); +var Lines = require('./graph2d_types/line'); +var Points = require('./graph2d_types/points'); var UNGROUPED = '__ungrouped__'; // reserved group id for ungrouped items @@ -27,11 +28,11 @@ function LineGraph(body, options) { defaultGroup: 'default', sort: true, sampling: true, - stack:false, + stack: false, graphHeight: '400px', shaded: { enabled: false, - orientation: 'bottom' // top, bottom + orientation: 'bottom' // top, bottom, zero }, style: 'line', // line, bar barChart: { @@ -49,42 +50,14 @@ function LineGraph(body, options) { size: 6, style: 'square' // square, circle }, - dataAxis: { - showMinorLabels: true, - showMajorLabels: true, - icons: false, - width: '40px', - visible: true, - alignZeros: true, - left:{ - range: {min:undefined,max:undefined}, - format: function (value) {return value;}, - title: {text:undefined,style:undefined} - }, - right:{ - range: {min:undefined,max:undefined}, - format: function (value) {return value;}, - title: {text:undefined,style:undefined} - } - }, - legend: { - enabled: false, - icons: true, - left: { - visible: true, - position: 'top-left' // top/bottom - left,right - }, - right: { - visible: true, - position: 'top-right' // top/bottom - left,right - } - }, + dataAxis: {}, //Defaults are done on DataAxis level + legend: {}, //Defaults are done on Legend level groups: { visibility: {} } }; - // options is shared by this ItemSet and all its items + // options is shared by this lineGraph and all its items this.options = util.extend({}, this.defaultOptions); this.dom = {}; this.props = {}; @@ -93,6 +66,7 @@ function LineGraph(body, options) { this.abortedGraphUpdate = false; this.updateSVGheight = false; this.updateSVGheightOnResize = false; + this.forceGraphUpdate = true; var me = this; this.itemsData = null; // DataSet @@ -132,18 +106,18 @@ function LineGraph(body, options) { this.svgElements = {}; this.setOptions(options); this.groupsUsingDefaultStyles = [0]; - this.COUNTER = 0; - this.body.emitter.on('rangechanged', function() { + this.body.emitter.on('rangechanged', function () { me.lastStart = me.body.range.start; me.svg.style.left = util.option.asSize(-me.props.width); - me.redraw.call(me,true); + + me.forceGraphUpdate = true; + //Is this local redraw necessary? (Core also does a change event!) + me.redraw.call(me); }); // create the HTML DOM this._create(); this.framework = {svg: this.svg, svgElements: this.svgElements, options: this.options, groups: this.groups}; - this.body.emitter.emit('change'); - } LineGraph.prototype = new Component(); @@ -151,15 +125,15 @@ LineGraph.prototype = new Component(); /** * Create the HTML DOM for the ItemSet */ -LineGraph.prototype._create = function(){ +LineGraph.prototype._create = function () { var frame = document.createElement('div'); frame.className = 'vis-line-graph'; this.dom.frame = frame; // create svg element for graph drawing. - this.svg = document.createElementNS('http://www.w3.org/2000/svg','svg'); + this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.svg.style.position = 'relative'; - this.svg.style.height = ('' + this.options.graphHeight).replace('px','') + 'px'; + this.svg.style.height = ('' + this.options.graphHeight).replace('px', '') + 'px'; this.svg.style.display = 'block'; frame.appendChild(this.svg); @@ -182,23 +156,23 @@ LineGraph.prototype._create = function(){ * set the options of the LineGraph. the mergeOptions is used for subObjects that have an enabled element. * @param {object} options */ -LineGraph.prototype.setOptions = function(options) { +LineGraph.prototype.setOptions = function (options) { if (options) { - var fields = ['sampling','defaultGroup','stack','height','graphHeight','yAxisOrientation','style','barChart','dataAxis','sort','groups']; - if (options.graphHeight === undefined && options.height !== undefined && this.body.domProps.centerContainer.height !== undefined) { + var fields = ['sampling', 'defaultGroup', 'stack', 'height', 'graphHeight', 'yAxisOrientation', 'style', 'barChart', 'dataAxis', 'sort', 'groups']; + if (options.graphHeight === undefined && options.height !== undefined) { this.updateSVGheight = true; this.updateSVGheightOnResize = true; } else if (this.body.domProps.centerContainer.height !== undefined && options.graphHeight !== undefined) { - if (parseInt((options.graphHeight + '').replace("px",'')) < this.body.domProps.centerContainer.height) { + if (parseInt((options.graphHeight + '').replace("px", '')) < this.body.domProps.centerContainer.height) { this.updateSVGheight = true; } } util.selectiveDeepExtend(fields, this.options, options); - util.mergeOptions(this.options, options,'interpolation'); - util.mergeOptions(this.options, options,'drawPoints'); - util.mergeOptions(this.options, options,'shaded'); - util.mergeOptions(this.options, options,'legend'); + util.mergeOptions(this.options, options, 'interpolation'); + util.mergeOptions(this.options, options, 'drawPoints'); + util.mergeOptions(this.options, options, 'shaded'); + util.mergeOptions(this.options, options, 'legend'); if (options.interpolation) { if (typeof options.interpolation == 'object') { @@ -237,15 +211,16 @@ LineGraph.prototype.setOptions = function(options) { } // this is used to redraw the graph if the visibility of the groups is changed. - if (this.dom.frame) { - this.redraw(true); + if (this.dom.frame) { //not on initial run? + this.forceGraphUpdate=true; + this.body.emitter.emit("_change",{queue: true}); } }; /** * Hide the component from the DOM */ -LineGraph.prototype.hide = function() { +LineGraph.prototype.hide = function () { // remove the frame containing the items if (this.dom.frame.parentNode) { this.dom.frame.parentNode.removeChild(this.dom.frame); @@ -257,7 +232,7 @@ LineGraph.prototype.hide = function() { * Show the component in the DOM (when not already visible). * @return {Boolean} changed */ -LineGraph.prototype.show = function() { +LineGraph.prototype.show = function () { // show frame containing the items if (!this.dom.frame.parentNode) { this.body.dom.center.appendChild(this.dom.frame); @@ -269,7 +244,7 @@ LineGraph.prototype.show = function() { * Set items * @param {vis.DataSet | null} items */ -LineGraph.prototype.setItems = function(items) { +LineGraph.prototype.setItems = function (items) { var me = this, ids, oldItemsData = this.itemsData; @@ -307,9 +282,6 @@ LineGraph.prototype.setItems = function(items) { ids = this.itemsData.getIds(); this._onAdd(ids); } - this._updateUngrouped(); - //this._updateGraph(); - this.redraw(true); }; @@ -317,7 +289,7 @@ LineGraph.prototype.setItems = function(items) { * Set groups * @param {vis.DataSet} groups */ -LineGraph.prototype.setGroups = function(groups) { +LineGraph.prototype.setGroups = function (groups) { var me = this; var ids; @@ -330,7 +302,9 @@ LineGraph.prototype.setGroups = function(groups) { // remove all drawn groups ids = this.groupsData.getIds(); this.groupsData = null; - this._onRemoveGroups(ids); // note: this will cause a redraw + for (var i = 0; i < ids.length; i++) { + this._removeGroup(ids[i]); + } } // replace the dataset @@ -355,34 +329,23 @@ LineGraph.prototype.setGroups = function(groups) { ids = this.groupsData.getIds(); this._onAddGroups(ids); } - this._onUpdate(); }; - -/** - * Update the data - * @param [ids] - * @private - */ -LineGraph.prototype._onUpdate = function(ids) { - this._updateUngrouped(); +LineGraph.prototype._onUpdate = function (ids) { this._updateAllGroupData(); - //this._updateGraph(); - this.redraw(true); }; -LineGraph.prototype._onAdd = function (ids) {this._onUpdate(ids);}; -LineGraph.prototype._onRemove = function (ids) {this._onUpdate(ids);}; -LineGraph.prototype._onUpdateGroups = function (groupIds) { - for (var i = 0; i < groupIds.length; i++) { - var group = this.groupsData.get(groupIds[i]); - this._updateGroup(group, groupIds[i]); - } - - //this._updateGraph(); - this.redraw(true); +LineGraph.prototype._onAdd = function (ids) { + this._onUpdate(ids); +}; +LineGraph.prototype._onRemove = function (ids) { + this._onUpdate(ids); +}; +LineGraph.prototype._onUpdateGroups = function (groupIds) { + this._updateAllGroupData(); +}; +LineGraph.prototype._onAddGroups = function (groupIds) { + this._onUpdateGroups(groupIds); }; -LineGraph.prototype._onAddGroups = function (groupIds) {this._onUpdateGroups(groupIds);}; - /** * this cleans the group out off the legends and the dataaxis, updates the ungrouped and updates the graph @@ -391,25 +354,32 @@ LineGraph.prototype._onAddGroups = function (groupIds) {this._onUpdateGroups(gro */ LineGraph.prototype._onRemoveGroups = function (groupIds) { for (var i = 0; i < groupIds.length; i++) { - if (this.groups.hasOwnProperty(groupIds[i])) { - if (this.groups[groupIds[i]].options.yAxisOrientation == 'right') { - this.yAxisRight.removeGroup(groupIds[i]); - this.legendRight.removeGroup(groupIds[i]); - this.legendRight.redraw(); - } - else { - this.yAxisLeft.removeGroup(groupIds[i]); - this.legendLeft.removeGroup(groupIds[i]); - this.legendLeft.redraw(); - } - delete this.groups[groupIds[i]]; - } + this._removeGroup(groupIds[i]); } - this._updateUngrouped(); - //this._updateGraph(); - this.redraw(true); + this.forceGraphUpdate = true; + this.body.emitter.emit("_change",{queue: true}); }; +/** + * this cleans the group out off the legends and the dataaxis + * @param groupId + * @private + */ +LineGraph.prototype._removeGroup = function (groupId) { + if (this.groups.hasOwnProperty(groupId)) { + if (this.groups[groupId].options.yAxisOrientation == 'right') { + this.yAxisRight.removeGroup(groupId); + this.legendRight.removeGroup(groupId); + this.legendRight.redraw(); + } + else { + this.yAxisLeft.removeGroup(groupId); + this.legendLeft.removeGroup(groupId); + this.legendLeft.redraw(); + } + delete this.groups[groupId]; + } +} /** * update a group object with the group dataset entree @@ -435,10 +405,16 @@ LineGraph.prototype._updateGroup = function (group, groupId) { if (this.groups[groupId].options.yAxisOrientation == 'right') { this.yAxisRight.updateGroup(groupId, this.groups[groupId]); this.legendRight.updateGroup(groupId, this.groups[groupId]); + //If yAxisOrientation changed, clean out the group from the other axis. + this.yAxisLeft.removeGroup(groupId); + this.legendLeft.removeGroup(groupId); } else { this.yAxisLeft.updateGroup(groupId, this.groups[groupId]); this.legendLeft.updateGroup(groupId, this.groups[groupId]); + //If yAxisOrientation changed, clean out the group from the other axis. + this.yAxisRight.removeGroup(groupId); + this.legendRight.removeGroup(groupId); } } this.legendLeft.redraw(); @@ -454,98 +430,83 @@ LineGraph.prototype._updateGroup = function (group, groupId) { LineGraph.prototype._updateAllGroupData = function () { if (this.itemsData != null) { var groupsContent = {}; - var groupId; - for (groupId in this.groups) { - if (this.groups.hasOwnProperty(groupId)) { - groupsContent[groupId] = []; + var items = this.itemsData.get(); + //pre-Determine array sizes, for more efficient memory claim + var groupCounts = {}; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var groupId = item.group; + if (groupId === null || groupId === undefined) { + groupId = UNGROUPED; } + groupCounts.hasOwnProperty(groupId) ? groupCounts[groupId]++ : groupCounts[groupId] = 1; } - for (var itemId in this.itemsData._data) { - if (this.itemsData._data.hasOwnProperty(itemId)) { - var item = this.itemsData._data[itemId]; - if (groupsContent[item.group] === undefined) { - throw new Error('Cannot find referenced group ' + item.group + '. Possible reason: items added before groups? Groups need to be added before items, as items refer to groups.') - } - item.x = util.convert(item.x,'Date'); - groupsContent[item.group].push(item); + //Now insert data into the arrays. + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var groupId = item.group; + if (groupId === null || groupId === undefined) { + groupId = UNGROUPED; } - } - for (groupId in this.groups) { - if (this.groups.hasOwnProperty(groupId)) { - this.groups[groupId].setItems(groupsContent[groupId]); + if (!groupsContent.hasOwnProperty(groupId)) { + groupsContent[groupId] = new Array(groupCounts[groupId]); } - } - } -}; + //Copy data (because of unmodifiable DataView input. + var extended = util.bridgeObject(item); + extended.x = util.convert(item.x, 'Date'); + extended.orginalY = item.y; //real Y + extended.y = Number(item.y); + var index= groupsContent[groupId].length - groupCounts[groupId]--; + groupsContent[groupId][index] = extended; + } -/** - * Create or delete the group holding all ungrouped items. This group is used when - * there are no groups specified. This anonymous group is called 'graph'. - * @protected - */ -LineGraph.prototype._updateUngrouped = function() { - if (this.itemsData && this.itemsData != null) { - var ungroupedCounter = 0; - for (var itemId in this.itemsData._data) { - if (this.itemsData._data.hasOwnProperty(itemId)) { - var item = this.itemsData._data[itemId]; - if (item != undefined) { - if (item.hasOwnProperty('group')) { - if (item.group === undefined) { - item.group = UNGROUPED; - } - } - else { - item.group = UNGROUPED; - } - ungroupedCounter = item.group == UNGROUPED ? ungroupedCounter + 1 : ungroupedCounter; + //Make sure all groups are present, to allow removal of old groups + for (var groupId in this.groups){ + if (this.groups.hasOwnProperty(groupId)){ + if (!groupsContent.hasOwnProperty(groupId)) { + groupsContent[groupId] = new Array(0); } } } - if (ungroupedCounter == 0) { - delete this.groups[UNGROUPED]; - this.legendLeft.removeGroup(UNGROUPED); - this.legendRight.removeGroup(UNGROUPED); - this.yAxisLeft.removeGroup(UNGROUPED); - this.yAxisRight.removeGroup(UNGROUPED); - } - else { - var group = {id: UNGROUPED, content: this.options.defaultGroup}; - this._updateGroup(group, UNGROUPED); + //Update legendas, style and axis + for (var groupId in groupsContent) { + if (groupsContent.hasOwnProperty(groupId)) { + if (groupsContent[groupId].length == 0) { + if (this.groups.hasOwnProperty(groupId)) { + this._removeGroup(groupId); + } + } else { + var group = undefined; + if (this.groupsData != undefined) { + group = this.groupsData.get(groupId); + } + if (group == undefined) { + group = {id: groupId, content: this.options.defaultGroup + groupId}; + } + this._updateGroup(group, groupId); + this.groups[groupId].setItems(groupsContent[groupId]); + } + } } + this.forceGraphUpdate = true; + this.body.emitter.emit("_change",{queue: true}); } - else { - delete this.groups[UNGROUPED]; - this.legendLeft.removeGroup(UNGROUPED); - this.legendRight.removeGroup(UNGROUPED); - this.yAxisLeft.removeGroup(UNGROUPED); - this.yAxisRight.removeGroup(UNGROUPED); - } - - this.legendLeft.redraw(); - this.legendRight.redraw(); }; - /** * Redraw the component, mandatory function * @return {boolean} Returns true if the component is resized */ -LineGraph.prototype.redraw = function(forceGraphUpdate) { +LineGraph.prototype.redraw = function () { var resized = false; // calculate actual size and position this.props.width = this.dom.frame.offsetWidth; this.props.height = this.body.domProps.centerContainer.height - - this.body.domProps.border.top - - this.body.domProps.border.bottom; - - // update the graph if there is no lastWidth or with, used for the initial draw - if (this.lastWidth === undefined && this.props.width) { - forceGraphUpdate = true; - } + - this.body.domProps.border.top + - this.body.domProps.border.bottom; // check if this component is resized resized = this._isResized() || resized; @@ -559,7 +520,7 @@ LineGraph.prototype.redraw = function(forceGraphUpdate) { // the svg element is three times as big as the width, this allows for fully dragging left and right // without reloading the graph. the controls for this are bound to events in the constructor if (resized == true) { - this.svg.style.width = util.option.asSize(3*this.props.width); + this.svg.style.width = util.option.asSize(3 * this.props.width); this.svg.style.left = util.option.asSize(-this.props.width); // if the height of the graph is set as proportional, change the height of the svg @@ -577,12 +538,13 @@ LineGraph.prototype.redraw = function(forceGraphUpdate) { this.updateSVGheight = false; } else { - this.svg.style.height = ('' + this.options.graphHeight).replace('px','') + 'px'; + this.svg.style.height = ('' + this.options.graphHeight).replace('px', '') + 'px'; } // zoomed is here to ensure that animations are shown correctly. - if (resized == true || zoomed == true || this.abortedGraphUpdate == true || forceGraphUpdate == true) { + if (resized == true || zoomed == true || this.abortedGraphUpdate == true || this.forceGraphUpdate == true) { resized = this._updateGraph() || resized; + this.forceGraphUpdate = false; } else { // move the whole svg while dragging @@ -590,19 +552,43 @@ LineGraph.prototype.redraw = function(forceGraphUpdate) { var offset = this.body.range.start - this.lastStart; var range = this.body.range.end - this.body.range.start; if (this.props.width != 0) { - var rangePerPixelInv = this.props.width/range; + var rangePerPixelInv = this.props.width / range; var xOffset = offset * rangePerPixelInv; this.svg.style.left = (-this.props.width - xOffset) + 'px'; } } } - this.legendLeft.redraw(); this.legendRight.redraw(); return resized; }; +LineGraph.prototype._getSortedGroupIds = function(){ + // getting group Ids + var grouplist = []; + for (var groupId in this.groups) { + if (this.groups.hasOwnProperty(groupId)) { + var group = this.groups[groupId]; + if (group.visible == true && (this.options.groups.visibility[groupId] === undefined || this.options.groups.visibility[groupId] == true)) { + grouplist.push({id:groupId,zIndex:group.options.zIndex}); + } + } + } + util.insertSort(grouplist,function(a,b){ + var az = a.zIndex; + var bz = b.zIndex; + if (az === undefined) az=0; + if (bz === undefined) bz=0; + return az==bz? 0: (az 0) { - // this is the range of the SVG canvas - var minDate = this.body.util.toGlobalTime(-this.body.domProps.root.width); - var maxDate = this.body.util.toGlobalTime(2 * this.body.domProps.root.width); var groupsData = {}; + // fill groups data, this only loads the data we require based on the timewindow this._getRelevantData(groupIds, groupsData, minDate, maxDate); @@ -640,44 +617,104 @@ LineGraph.prototype._updateGraph = function () { // we transform the X coordinates to detect collisions for (i = 0; i < groupIds.length; i++) { - preprocessedGroupData[groupIds[i]] = this._convertXcoordinates(groupsData[groupIds[i]]); + this._convertXcoordinates(groupsData[groupIds[i]]); } // now all needed data has been collected we start the processing. - this._getYRanges(groupIds, preprocessedGroupData, groupRanges); + this._getYRanges(groupIds, groupsData, groupRanges); // update the Y axis first, we use this data to draw at the correct Y points - // changeCalled is required to clean the SVG on a change emit. changeCalled = this._updateYAxis(groupIds, groupRanges); - var MAX_CYCLES = 5; - if (changeCalled == true && this.COUNTER < MAX_CYCLES) { + + // at changeCalled, abort this update cycle as the graph needs another update with new Width input from the Redraw container. + // Cleanup SVG elements on abort. + if (changeCalled == true) { DOMutil.cleanupElements(this.svgElements); this.abortedGraphUpdate = true; - this.COUNTER++; - this.body.emitter.emit('change'); return true; } - else { - if (this.COUNTER > MAX_CYCLES) { - console.log("WARNING: there may be an infinite loop in the _updateGraph emitter cycle."); - } - this.COUNTER = 0; - this.abortedGraphUpdate = false; + this.abortedGraphUpdate = false; - // With the yAxis scaled correctly, use this to get the Y values of the points. - for (i = 0; i < groupIds.length; i++) { - group = this.groups[groupIds[i]]; - processedGroupData[groupIds[i]] = this._convertYcoordinates(groupsData[groupIds[i]], group); - } - - // draw the groups - for (i = 0; i < groupIds.length; i++) { - group = this.groups[groupIds[i]]; - if (group.options.style != 'bar') { // bar needs to be drawn enmasse - group.draw(processedGroupData[groupIds[i]], group, this.framework); + // With the yAxis scaled correctly, use this to get the Y values of the points. + var below = undefined; + for (i = 0; i < groupIds.length; i++) { + group = this.groups[groupIds[i]]; + if (this.options.stack === true && this.options.style === 'line') { + if (group.options.excludeFromStacking == undefined || !group.options.excludeFromStacking) { + if (below != undefined) { + this._stack(groupsData[group.id], groupsData[below.id]); + if (group.options.shaded.enabled == true && group.options.shaded.orientation !== "group"){ + if (group.options.shaded.orientation == "top" && below.options.shaded.orientation !== "group"){ + below.options.shaded.orientation="group"; + below.options.shaded.groupId=group.id; + } else { + group.options.shaded.orientation="group"; + group.options.shaded.groupId=below.id; + } + } + } + below = group; } } - BarFunctions.draw(groupIds, processedGroupData, this.framework); + this._convertYcoordinates(groupsData[groupIds[i]], group); + } + + //Precalculate paths and draw shading if appropriate. This will make sure the shading is always behind any lines. + var paths = {}; + for (i = 0; i < groupIds.length; i++) { + group = this.groups[groupIds[i]]; + if (group.options.style === 'line' && group.options.shaded.enabled == true) { + var dataset = groupsData[groupIds[i]]; + if (dataset == null || dataset.length == 0) { + continue; + } + if (!paths.hasOwnProperty(groupIds[i])) { + paths[groupIds[i]] = Lines.calcPath(dataset, group); + } + if (group.options.shaded.orientation === "group") { + var subGroupId = group.options.shaded.groupId; + if (groupIds.indexOf(subGroupId) === -1) { + console.log(group.id + ": Unknown shading group target given:" + subGroupId); + continue; + } + if (!paths.hasOwnProperty(subGroupId)) { + paths[subGroupId] = Lines.calcPath(groupsData[subGroupId], this.groups[subGroupId]); + } + Lines.drawShading(paths[groupIds[i]], group, paths[subGroupId], this.framework); + } + else { + Lines.drawShading(paths[groupIds[i]], group, undefined, this.framework); + } + } + } + + // draw the groups, calculating paths if still necessary. + Bars.draw(groupIds, groupsData, this.framework); + for (i = 0; i < groupIds.length; i++) { + group = this.groups[groupIds[i]]; + if (groupsData[groupIds[i]].length > 0) { + switch (group.options.style) { + case "line": + if (!paths.hasOwnProperty(groupIds[i])) { + paths[groupIds[i]] = Lines.calcPath(groupsData[groupIds[i]], group); + } + Lines.draw(paths[groupIds[i]], group, this.framework); + //explicit no break; + case "point": + //explicit no break; + case "points": + if (group.options.style == "point" || group.options.style == "points" || group.options.drawPoints.enabled == true) { + Points.draw(groupsData[groupIds[i]], group, this.framework); + } + break; + case "bar": + // bar needs to be drawn enmasse + //explicit no break + default: + //do nothing... + } + } + } } } @@ -687,6 +724,51 @@ LineGraph.prototype._updateGraph = function () { return false; }; +LineGraph.prototype._stack = function (data, subData) { + var index, dx, dy, subPrevPoint, subNextPoint; + index = 0; + // for each data point we look for a matching on in the set below + for (var j = 0; j < data.length; j++) { + subPrevPoint = undefined; + subNextPoint = undefined; + // we look for time matches or a before-after point + for (var k = index; k < subData.length; k++) { + // if times match exactly + if (subData[k].x === data[j].x) { + subPrevPoint = subData[k]; + subNextPoint = subData[k]; + index = k; + break; + } + else if (subData[k].x > data[j].x) { // overshoot + subNextPoint = subData[k]; + if (k == 0) { + subPrevPoint = subNextPoint; + } + else { + subPrevPoint = subData[k - 1]; + } + index = k; + break; + } + } + // in case the last data point has been used, we assume it stays like this. + if (subNextPoint === undefined) { + subPrevPoint = subData[subData.length - 1]; + subNextPoint = subData[subData.length - 1]; + } + // linear interpolation + dx = subNextPoint.x - subPrevPoint.x; + dy = subNextPoint.y - subPrevPoint.y; + if (dx == 0) { + data[j].y = data[j].orginalY + subNextPoint.y; + } + else { + data[j].y = data[j].orginalY + (dy / dx) * (data[j].x - subPrevPoint.x) + subPrevPoint.y; // ax + b where b is data[j].y + } + } +} + /** * first select and preprocess the data from the datasets. @@ -706,33 +788,27 @@ LineGraph.prototype._getRelevantData = function (groupIds, groupsData, minDate, if (groupIds.length > 0) { for (i = 0; i < groupIds.length; i++) { group = this.groups[groupIds[i]]; - groupsData[groupIds[i]] = []; - var dataContainer = groupsData[groupIds[i]]; + var itemsData = group.getItems(); // optimization for sorted data if (group.options.sort == true) { - var guess = Math.max(0, util.binarySearchValue(group.itemsData, minDate, 'x', 'before')); - for (j = guess; j < group.itemsData.length; j++) { - item = group.itemsData[j]; - if (item !== undefined) { - if (item.x > maxDate) { - dataContainer.push(item); - break; - } - else { - dataContainer.push(item); - } - } + var dateComparator = function (a, b) { + return a.getTime() == b.getTime() ? 0 : a < b ? -1 : 1 + }; + var first = Math.max(0, util.binarySearchValue(itemsData, minDate, 'x', 'before', dateComparator)); + var last = Math.min(itemsData.length, util.binarySearchValue(itemsData, maxDate, 'x', 'after', dateComparator) + 1); + if (last <= 0) { + last = itemsData.length; } + var dataContainer = new Array(last-first); + for (j = first; j < last; j++) { + item = group.itemsData[j]; + dataContainer[j-first] = item; + } + groupsData[groupIds[i]] = dataContainer; } else { - for (j = 0; j < group.itemsData.length; j++) { - item = group.itemsData[j]; - if (item !== undefined) { - if (item.x > minDate && item.x < maxDate) { - dataContainer.push(item); - } - } - } + // If unsorted data, all data is relevant, just returning entire structure + groupsData[groupIds[i]] = group.itemsData; } } } @@ -762,12 +838,12 @@ LineGraph.prototype._applySampling = function (groupIds, groupsData) { var pointsPerPixel = amountOfPoints / xDistance; increment = Math.min(Math.ceil(0.2 * amountOfPoints), Math.max(1, Math.round(pointsPerPixel))); - var sampledData = []; + var sampledData = new Array(amountOfPoints); for (var j = 0; j < amountOfPoints; j += increment) { - sampledData.push(dataContainer[j]); - + var idx = Math.round(j/increment); + sampledData[idx]=dataContainer[j]; } - groupsData[groupIds[i]] = sampledData; + groupsData[groupIds[i]] = sampledData.splice(0,Math.round(amountOfPoints/increment)); } } } @@ -796,22 +872,22 @@ LineGraph.prototype._getYRanges = function (groupIds, groupsData, groupRanges) { group = this.groups[groupIds[i]]; // if bar graphs are stacked, their range need to be handled differently and accumulated over all groups. if (options.stack === true && options.style === 'bar') { - if (options.yAxisOrientation === 'left') {combinedDataLeft = combinedDataLeft .concat(group.getData(groupData));} - else {combinedDataRight = combinedDataRight.concat(group.getData(groupData));} + if (options.yAxisOrientation === 'left') { + combinedDataLeft = combinedDataLeft.concat(group.getItems()); + } + else { + combinedDataRight = combinedDataRight.concat(group.getItems()); + } } else { - groupRanges[groupIds[i]] = group.getYRange(groupData,groupIds[i]); + groupRanges[groupIds[i]] = group.getYRange(groupData, groupIds[i]); } } } // if bar graphs are stacked, their range need to be handled differently and accumulated over all groups. - BarFunctions.getStackedYRange(combinedDataLeft , groupRanges, groupIds, '__barStackLeft' , 'left' ); - BarFunctions.getStackedYRange(combinedDataRight, groupRanges, groupIds, '__barStackRight', 'right'); - // if line graphs are stacked, their range need to be handled differently and accumulated over all groups. - //LineFunctions.getStackedYRange(combinedDataLeft , groupRanges, groupIds, '__lineStackLeft' , 'left' ); - //LineFunctions.getStackedYRange(combinedDataRight, groupRanges, groupIds, '__lineStackRight', 'right'); - + Bars.getStackedYRange(combinedDataLeft, groupRanges, groupIds, '__barStackLeft', 'left'); + Bars.getStackedYRange(combinedDataRight, groupRanges, groupIds, '__barStackRight', 'right'); } }; @@ -872,7 +948,7 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) { this.yAxisRight.setRange(minRight, maxRight); } } - resized = this._toggleAxisVisiblity(yAxisLeftUsed , this.yAxisLeft) || resized; + resized = this._toggleAxisVisiblity(yAxisLeftUsed, this.yAxisLeft) || resized; resized = this._toggleAxisVisiblity(yAxisRightUsed, this.yAxisRight) || resized; if (yAxisRightUsed == true && yAxisLeftUsed == true) { @@ -884,14 +960,17 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) { this.yAxisRight.drawIcons = false; } this.yAxisRight.master = !yAxisLeftUsed; + this.yAxisRight.masterAxis = this.yAxisLeft; + if (this.yAxisRight.master == false) { - if (yAxisRightUsed == true) {this.yAxisLeft.lineOffset = this.yAxisRight.width;} - else {this.yAxisLeft.lineOffset = 0;} + if (yAxisRightUsed == true) { + this.yAxisLeft.lineOffset = this.yAxisRight.width; + } + else { + this.yAxisLeft.lineOffset = 0; + } resized = this.yAxisLeft.redraw() || resized; - this.yAxisRight.stepPixels = this.yAxisLeft.stepPixels; - this.yAxisRight.zeroCrossing = this.yAxisLeft.zeroCrossing; - this.yAxisRight.amountOfSteps = this.yAxisLeft.amountOfSteps; resized = this.yAxisRight.redraw() || resized; } else { @@ -899,9 +978,11 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) { } // clean the accumulated lists - var tempGroups = ['__barStackLeft','__barStackRight','__lineStackLeft','__lineStackRight']; + var tempGroups = ['__barStackLeft', '__barStackRight', '__lineStackLeft', '__lineStackRight']; for (var i = 0; i < tempGroups.length; i++) { - if (groupIds.indexOf(tempGroups[i]) != -1) {groupIds.splice(groupIds.indexOf(tempGroups[i]),1);} + if (groupIds.indexOf(tempGroups[i]) != -1) { + groupIds.splice(groupIds.indexOf(tempGroups[i]), 1); + } } return resized; @@ -920,7 +1001,7 @@ LineGraph.prototype._toggleAxisVisiblity = function (axisUsed, axis) { var changed = false; if (axisUsed == false) { if (axis.dom.frame.parentNode && axis.hidden == false) { - axis.hide() + axis.hide(); changed = true; } } @@ -944,17 +1025,11 @@ LineGraph.prototype._toggleAxisVisiblity = function (axisUsed, axis) { * @private */ LineGraph.prototype._convertXcoordinates = function (datapoints) { - var extractedData = []; - var xValue, yValue; var toScreen = this.body.util.toScreen; - for (var i = 0; i < datapoints.length; i++) { - xValue = toScreen(datapoints[i].x) + this.props.width; - yValue = datapoints[i].y; - extractedData.push({x: xValue, y: yValue}); + datapoints[i].screen_x = toScreen(datapoints[i].x) + this.props.width; + datapoints[i].screen_y = datapoints[i].y; //starting point for range calculations } - - return extractedData; }; @@ -969,25 +1044,15 @@ LineGraph.prototype._convertXcoordinates = function (datapoints) { * @private */ LineGraph.prototype._convertYcoordinates = function (datapoints, group) { - var extractedData = []; - var xValue, yValue; - var toScreen = this.body.util.toScreen; var axis = this.yAxisLeft; - var svgHeight = Number(this.svg.style.height.replace('px','')); + var svgHeight = Number(this.svg.style.height.replace('px', '')); if (group.options.yAxisOrientation == 'right') { axis = this.yAxisRight; } - for (var i = 0; i < datapoints.length; i++) { - var labelValue = datapoints[i].label ? datapoints[i].label : null; - xValue = toScreen(datapoints[i].x) + this.props.width; - yValue = Math.round(axis.convertValue(datapoints[i].y)); - extractedData.push({x: xValue, y: yValue, label:labelValue}); + datapoints[i].screen_y = Math.round(axis.convertValue(datapoints[i].y)); } - group.setZeroPosition(Math.min(svgHeight, axis.convertValue(0))); - - return extractedData; }; diff --git a/lib/timeline/component/TimeAxis.js b/lib/timeline/component/TimeAxis.js index d1e262715..136179b41 100644 --- a/lib/timeline/component/TimeAxis.js +++ b/lib/timeline/component/TimeAxis.js @@ -39,7 +39,9 @@ function TimeAxis (body, options) { }, // axis orientation: 'top' or 'bottom' showMinorLabels: true, showMajorLabels: true, + maxMinorChars: 7, format: TimeStep.FORMAT, + moment: moment, timeAxis: null }; this.options = util.extend({}, this.defaultOptions); @@ -68,8 +70,10 @@ TimeAxis.prototype.setOptions = function(options) { util.selectiveExtend([ 'showMinorLabels', 'showMajorLabels', + 'maxMinorChars', 'hiddenDates', - 'timeAxis' + 'timeAxis', + 'moment' ], this.options, options); // deep copy the format options @@ -193,11 +197,12 @@ TimeAxis.prototype._repaintLabels = function () { // calculate range and step (step such that we have space for 7 characters per label) var start = util.convert(this.body.range.start, 'Number'); var end = util.convert(this.body.range.end, 'Number'); - var timeLabelsize = this.body.util.toTime((this.props.minorCharWidth || 10) * 7).valueOf(); - var minimumStep = timeLabelsize - DateUtil.getHiddenDurationBefore(this.body.hiddenDates, this.body.range, timeLabelsize); + var timeLabelsize = this.body.util.toTime((this.props.minorCharWidth || 10) * this.options.maxMinorChars).valueOf(); + var minimumStep = timeLabelsize - DateUtil.getHiddenDurationBefore(this.options.moment, this.body.hiddenDates, this.body.range, timeLabelsize); minimumStep -= this.body.util.toTime(0).valueOf(); var step = new TimeStep(new Date(start), new Date(end), minimumStep, this.body.hiddenDates); + step.setMoment(this.options.moment); if (this.options.format) { step.setFormat(this.options.format); } @@ -221,19 +226,20 @@ TimeAxis.prototype._repaintLabels = function () { var next; var x; var xNext; - var isMajor; - var width; + var isMajor, nextIsMajor; + var width = 0, prevWidth; var line; var labelMinor; var xFirstMajorLabel = undefined; - var max = 0; + var count = 0; + const MAX = 1000; var className; - step.first(); + step.start(); next = step.getCurrent(); xNext = this.body.util.toScreen(next); - while (step.hasNext() && max < 1000) { - max++; + while (step.hasNext() && count < MAX) { + count++; isMajor = step.isMajor(); className = step.getClassName(); @@ -244,13 +250,16 @@ TimeAxis.prototype._repaintLabels = function () { step.next(); next = step.getCurrent(); + nextIsMajor = step.isMajor(); xNext = this.body.util.toScreen(next); + prevWidth = width; width = xNext - x; - var labelFits = labelMinor.length * this.props.minorCharWidth < width; + var showMinorGrid = (width >= prevWidth * 0.4); // prevent displaying of the 31th of the month on a scale of 5 days - if (this.options.showMinorLabels && labelFits) { - this._repaintMinorText(x, labelMinor, orientation, className); + if (this.options.showMinorLabels && showMinorGrid) { + var label = this._repaintMinorText(x, labelMinor, orientation, className); + label.style.width = width + 'px'; // set width to prevent overflow } if (isMajor && this.options.showMajorLabels) { @@ -258,22 +267,28 @@ TimeAxis.prototype._repaintLabels = function () { if (xFirstMajorLabel == undefined) { xFirstMajorLabel = x; } - this._repaintMajorText(x, step.getLabelMajor(), orientation, className); + label = this._repaintMajorText(x, step.getLabelMajor(), orientation, className); } line = this._repaintMajorLine(x, width, orientation, className); } - else { - if (labelFits) { + else { // minor line + if (showMinorGrid) { line = this._repaintMinorLine(x, width, orientation, className); } else { if (line) { - line.style.width = (parseInt (line.style.width) + width) + 'px' + // adjust the width of the previous grid + line.style.width = (parseInt (line.style.width) + width) + 'px'; } } } } + if (count === MAX && !warnedForOverflow) { + console.warn(`Something is wrong with the Timeline scale. Limited drawing of grid lines to ${MAX} lines.`); + warnedForOverflow = true; + } + // create a major label on the left when needed if (this.options.showMajorLabels) { var leftTime = this.body.util.toTime(0), @@ -464,4 +479,7 @@ TimeAxis.prototype._calculateCharSize = function () { this.props.majorCharWidth = this.dom.measureCharMajor.clientWidth; }; + +var warnedForOverflow = false; + module.exports = TimeAxis; diff --git a/lib/timeline/component/css/item.css b/lib/timeline/component/css/item.css index e843e1e0a..4e0488851 100644 --- a/lib/timeline/component/css/item.css +++ b/lib/timeline/component/css/item.css @@ -123,3 +123,8 @@ cursor: e-resize; } + +.vis-range.vis-item.vis-readonly .vis-drag-left, +.vis-range.vis-item.vis-readonly .vis-drag-right { + cursor: auto; +} diff --git a/lib/timeline/component/css/labelset.css b/lib/timeline/component/css/labelset.css index c2690e971..3aeabd102 100644 --- a/lib/timeline/component/css/labelset.css +++ b/lib/timeline/component/css/labelset.css @@ -21,6 +21,10 @@ border-bottom: 1px solid #bfbfbf; } +.vis-labelset .vis-label.draggable { + cursor: pointer; +} + .vis-labelset .vis-label:last-child { border-bottom: none; } diff --git a/lib/timeline/component/graph2d_types/bar.js b/lib/timeline/component/graph2d_types/bar.js index c5474b946..32b3d36e7 100644 --- a/lib/timeline/component/graph2d_types/bar.js +++ b/lib/timeline/component/graph2d_types/bar.js @@ -2,35 +2,43 @@ var DOMutil = require('../../../DOMutil'); var Points = require('./points'); function Bargraph(groupId, options) { - this.groupId = groupId; - this.options = options; } -Bargraph.prototype.getYRange = function(groupData) { - var yMin = groupData[0].y; - var yMax = groupData[0].y; - for (var j = 0; j < groupData.length; j++) { - yMin = yMin > groupData[j].y ? groupData[j].y : yMin; - yMax = yMax < groupData[j].y ? groupData[j].y : yMax; - } - return {min: yMin, max: yMax, yAxisOrientation: this.options.yAxisOrientation}; -}; +Bargraph.drawIcon = function (group, x, y, iconWidth, iconHeight, framework) { + var fillHeight = iconHeight * 0.5; + var path, fillPath; + var outline = DOMutil.getSVGElement("rect", framework.svgElements, framework.svg); + outline.setAttributeNS(null, "x", x); + outline.setAttributeNS(null, "y", y - fillHeight); + outline.setAttributeNS(null, "width", iconWidth); + outline.setAttributeNS(null, "height", 2 * fillHeight); + outline.setAttributeNS(null, "class", "vis-outline"); -Bargraph.prototype.getData = function(groupData) { - var combinedData = []; - for (var j = 0; j < groupData.length; j++) { - combinedData.push({ - x: groupData[j].x, - y: groupData[j].y, - groupId: this.groupId - }); + var barWidth = Math.round(0.3 * iconWidth); + var originalWidth = group.options.barChart.width; + var scale = originalWidth / barWidth; + var bar1Height = Math.round(0.4 * iconHeight); + var bar2Height = Math.round(0.75 * iconHeight); + + var offset = Math.round((iconWidth - (2 * barWidth)) / 3); + + DOMutil.drawBar(x + 0.5 * barWidth + offset, y + fillHeight - bar1Height - 1, barWidth, bar1Height, group.className + ' vis-bar', framework.svgElements, framework.svg, group.style); + DOMutil.drawBar(x + 1.5 * barWidth + offset + 2, y + fillHeight - bar2Height - 1, barWidth, bar2Height, group.className + ' vis-bar', framework.svgElements, framework.svg, group.style); + + if (group.options.drawPoints.enabled == true) { + var groupTemplate = { + style: group.options.drawPoints.style, + styles: group.options.drawPoints.styles, + size: (group.options.drawPoints.size / scale), + className: group.className + }; + DOMutil.drawPoint(x + 0.5 * barWidth + offset, y + fillHeight - bar1Height - 1, groupTemplate, framework.svgElements, framework.svg); + DOMutil.drawPoint(x + 1.5 * barWidth + offset + 2, y + fillHeight - bar2Height - 1, groupTemplate, framework.svgElements, framework.svg); } - return combinedData; + } - - /** * draw a bar graph * @@ -43,7 +51,7 @@ Bargraph.draw = function (groupIds, processedGroupData, framework) { var coreDistance; var key, drawData; var group; - var i,j; + var i, j; var barPoints = 0; // combine all barchart data @@ -53,6 +61,8 @@ Bargraph.draw = function (groupIds, processedGroupData, framework) { if (group.visible === true && (framework.options.groups.visibility[groupIds[i]] === undefined || framework.options.groups.visibility[groupIds[i]] === true)) { for (j = 0; j < processedGroupData[groupIds[i]].length; j++) { combinedData.push({ + screen_x: processedGroupData[groupIds[i]][j].screen_x, + screen_y: processedGroupData[groupIds[i]][j].screen_y, x: processedGroupData[groupIds[i]][j].x, y: processedGroupData[groupIds[i]][j].y, groupId: groupIds[i], @@ -64,15 +74,17 @@ Bargraph.draw = function (groupIds, processedGroupData, framework) { } } - if (barPoints === 0) {return;} + if (barPoints === 0) { + return; + } // sort by time and by group combinedData.sort(function (a, b) { - if (a.x === b.x) { + if (a.screen_x === b.screen_x) { return a.groupId < b.groupId ? -1 : 1; } else { - return a.x - b.x; + return a.screen_x - b.screen_x; } }); @@ -82,44 +94,52 @@ Bargraph.draw = function (groupIds, processedGroupData, framework) { // plot barchart for (i = 0; i < combinedData.length; i++) { group = framework.groups[combinedData[i].groupId]; - var minWidth = 0.1 * group.options.barChart.width; + var minWidth = group.options.barChart.minWidth != undefined ? group.options.barChart.minWidth : 0.1 * group.options.barChart.width; - key = combinedData[i].x; + key = combinedData[i].screen_x; var heightOffset = 0; if (intersections[key] === undefined) { - if (i+1 < combinedData.length) {coreDistance = Math.abs(combinedData[i+1].x - key);} - if (i > 0) {coreDistance = Math.min(coreDistance,Math.abs(combinedData[i-1].x - key));} + if (i + 1 < combinedData.length) { + coreDistance = Math.abs(combinedData[i + 1].screen_x - key); + } drawData = Bargraph._getSafeDrawData(coreDistance, group, minWidth); } else { var nextKey = i + (intersections[key].amount - intersections[key].resolved); var prevKey = i - (intersections[key].resolved + 1); - if (nextKey < combinedData.length) {coreDistance = Math.abs(combinedData[nextKey].x - key);} - if (prevKey > 0) {coreDistance = Math.min(coreDistance,Math.abs(combinedData[prevKey].x - key));} + if (nextKey < combinedData.length) { + coreDistance = Math.abs(combinedData[nextKey].screen_x - key); + } drawData = Bargraph._getSafeDrawData(coreDistance, group, minWidth); intersections[key].resolved += 1; - if (group.options.stack === true) { - if (combinedData[i].y < group.zeroPosition) { + if (group.options.stack === true && group.options.excludeFromStacking !== true) { + if (combinedData[i].screen_y < group.zeroPosition) { heightOffset = intersections[key].accumulatedNegative; - intersections[key].accumulatedNegative += group.zeroPosition - combinedData[i].y; + intersections[key].accumulatedNegative += group.zeroPosition - combinedData[i].screen_y; } else { heightOffset = intersections[key].accumulatedPositive; - intersections[key].accumulatedPositive += group.zeroPosition - combinedData[i].y; + intersections[key].accumulatedPositive += group.zeroPosition - combinedData[i].screen_y; } } else if (group.options.barChart.sideBySide === true) { drawData.width = drawData.width / intersections[key].amount; - drawData.offset += (intersections[key].resolved) * drawData.width - (0.5*drawData.width * (intersections[key].amount+1)); - if (group.options.barChart.align === 'left') {drawData.offset -= 0.5*drawData.width;} - else if (group.options.barChart.align === 'right') {drawData.offset += 0.5*drawData.width;} + drawData.offset += (intersections[key].resolved) * drawData.width - (0.5 * drawData.width * (intersections[key].amount + 1)); } } - DOMutil.drawBar(combinedData[i].x + drawData.offset, combinedData[i].y - heightOffset, drawData.width, group.zeroPosition - combinedData[i].y, group.className + ' vis-bar', framework.svgElements, framework.svg, group.style); + DOMutil.drawBar(combinedData[i].screen_x + drawData.offset, combinedData[i].screen_y - heightOffset, drawData.width, group.zeroPosition - combinedData[i].screen_y, group.className + ' vis-bar', framework.svgElements, framework.svg, group.style); // draw points if (group.options.drawPoints.enabled === true) { - Points.draw([combinedData[i]], group, framework, drawData.offset); + let pointData = { + screen_x: combinedData[i].screen_x, + screen_y: combinedData[i].screen_y - heightOffset, + x: combinedData[i].x, + y: combinedData[i].y, + groupId: combinedData[i].groupId, + label: combinedData[i].label + }; + Points.draw([pointData], group, framework, drawData.offset); //DOMutil.drawPoint(combinedData[i].x + drawData.offset, combinedData[i].y, group, framework.svgElements, framework.svg); } } @@ -137,16 +157,21 @@ Bargraph._getDataIntersections = function (intersections, combinedData) { var coreDistance; for (var i = 0; i < combinedData.length; i++) { if (i + 1 < combinedData.length) { - coreDistance = Math.abs(combinedData[i + 1].x - combinedData[i].x); + coreDistance = Math.abs(combinedData[i + 1].screen_x - combinedData[i].screen_x); } if (i > 0) { - coreDistance = Math.min(coreDistance, Math.abs(combinedData[i - 1].x - combinedData[i].x)); + coreDistance = Math.min(coreDistance, Math.abs(combinedData[i - 1].screen_x - combinedData[i].screen_x)); } if (coreDistance === 0) { - if (intersections[combinedData[i].x] === undefined) { - intersections[combinedData[i].x] = {amount: 0, resolved: 0, accumulatedPositive: 0, accumulatedNegative: 0}; + if (intersections[combinedData[i].screen_x] === undefined) { + intersections[combinedData[i].screen_x] = { + amount: 0, + resolved: 0, + accumulatedPositive: 0, + accumulatedNegative: 0 + }; } - intersections[combinedData[i].x].amount += 1; + intersections[combinedData[i].screen_x].amount += 1; } } }; @@ -164,7 +189,7 @@ Bargraph._getDataIntersections = function (intersections, combinedData) { Bargraph._getSafeDrawData = function (coreDistance, group, minWidth) { var width, offset; if (coreDistance < group.options.barChart.width && coreDistance > 0) { - width = coreDistance < minWidth ? minWidth : coreDistance; + width = coreDistance < minWidth ? minWidth : coreDistance offset = 0; // recalculate offset with the new width; if (group.options.barChart.align === 'left') { @@ -189,15 +214,15 @@ Bargraph._getSafeDrawData = function (coreDistance, group, minWidth) { return {width: width, offset: offset}; }; -Bargraph.getStackedYRange = function(combinedData, groupRanges, groupIds, groupLabel, orientation) { +Bargraph.getStackedYRange = function (combinedData, groupRanges, groupIds, groupLabel, orientation) { if (combinedData.length > 0) { // sort by time and by group combinedData.sort(function (a, b) { - if (a.x === b.x) { + if (a.screen_x === b.screen_x) { return a.groupId < b.groupId ? -1 : 1; - } + } else { - return a.x - b.x; + return a.screen_x - b.screen_x; } }); var intersections = {}; @@ -211,20 +236,20 @@ Bargraph.getStackedYRange = function(combinedData, groupRanges, groupIds, groupL Bargraph._getStackedYRange = function (intersections, combinedData) { var key; - var yMin = combinedData[0].y; - var yMax = combinedData[0].y; + var yMin = combinedData[0].screen_y; + var yMax = combinedData[0].screen_y; for (var i = 0; i < combinedData.length; i++) { - key = combinedData[i].x; + key = combinedData[i].screen_x; if (intersections[key] === undefined) { - yMin = yMin > combinedData[i].y ? combinedData[i].y : yMin; - yMax = yMax < combinedData[i].y ? combinedData[i].y : yMax; + yMin = yMin > combinedData[i].screen_y ? combinedData[i].screen_y : yMin; + yMax = yMax < combinedData[i].screen_y ? combinedData[i].screen_y : yMax; } else { - if (combinedData[i].y < 0) { - intersections[key].accumulatedNegative += combinedData[i].y; + if (combinedData[i].screen_y < 0) { + intersections[key].accumulatedNegative += combinedData[i].screen_y; } else { - intersections[key].accumulatedPositive += combinedData[i].y; + intersections[key].accumulatedPositive += combinedData[i].screen_y; } } } diff --git a/lib/timeline/component/graph2d_types/line.js b/lib/timeline/component/graph2d_types/line.js index 7d70e8d31..18b9877b4 100644 --- a/lib/timeline/component/graph2d_types/line.js +++ b/lib/timeline/component/graph2d_types/line.js @@ -1,109 +1,112 @@ var DOMutil = require('../../../DOMutil'); -var Points = require('./points'); function Line(groupId, options) { - this.groupId = groupId; - this.options = options; } -Line.prototype.getData = function(groupData) { - var combinedData = []; - for (var j = 0; j < groupData.length; j++) { - combinedData.push({ - x: groupData[j].x, - y: groupData[j].y, - groupId: this.groupId - }); - } - return combinedData; +Line.calcPath = function (dataset, group) { + if (dataset != null) { + if (dataset.length > 0) { + var d = []; + + // construct path from dataset + if (group.options.interpolation.enabled == true) { + d = Line._catmullRom(dataset, group); + } + else { + d = Line._linear(dataset); + } + return d; + } + } } -Line.prototype.getYRange = function(groupData) { - var yMin = groupData[0].y; - var yMax = groupData[0].y; - for (var j = 0; j < groupData.length; j++) { - yMin = yMin > groupData[j].y ? groupData[j].y : yMin; - yMax = yMax < groupData[j].y ? groupData[j].y : yMax; - } - return {min: yMin, max: yMax, yAxisOrientation: this.options.yAxisOrientation}; -}; +Line.drawIcon = function (group, x, y, iconWidth, iconHeight, framework) { + var fillHeight = iconHeight * 0.5; + var path, fillPath; -Line.getStackedYRange = function(combinedData, groupRanges, groupIds, groupLabel, orientation) { - if (combinedData.length > 0) { - // sort by time and by group - combinedData.sort(function (a, b) { - if (a.x === b.x) { - return a.groupId < b.groupId ? -1 : 1; - } - else { - return a.x - b.x; - } - }); - var intersections = {}; + var outline = DOMutil.getSVGElement("rect", framework.svgElements, framework.svg); + outline.setAttributeNS(null, "x", x); + outline.setAttributeNS(null, "y", y - fillHeight); + outline.setAttributeNS(null, "width", iconWidth); + outline.setAttributeNS(null, "height", 2 * fillHeight); + outline.setAttributeNS(null, "class", "vis-outline"); - Line._getDataIntersections(intersections, combinedData); - groupRanges[groupLabel] = Line._getStackedYRange(intersections, combinedData); - groupRanges[groupLabel].yAxisOrientation = orientation; - groupIds.push(groupLabel); - } + path = DOMutil.getSVGElement("path", framework.svgElements, framework.svg); + path.setAttributeNS(null, "class", group.className); + if (group.style !== undefined) { + path.setAttributeNS(null, "style", group.style); + } + + path.setAttributeNS(null, "d", "M" + x + "," + y + " L" + (x + iconWidth) + "," + y + ""); + if (group.options.shaded.enabled == true) { + fillPath = DOMutil.getSVGElement("path", framework.svgElements, framework.svg); + if (group.options.shaded.orientation == 'top') { + fillPath.setAttributeNS(null, "d", "M" + x + ", " + (y - fillHeight) + + "L" + x + "," + y + " L" + (x + iconWidth) + "," + y + " L" + (x + iconWidth) + "," + (y - fillHeight)); + } + else { + fillPath.setAttributeNS(null, "d", "M" + x + "," + y + " " + + "L" + x + "," + (y + fillHeight) + " " + + "L" + (x + iconWidth) + "," + (y + fillHeight) + + "L" + (x + iconWidth) + "," + y); + } + fillPath.setAttributeNS(null, "class", group.className + " vis-icon-fill"); + if (group.options.shaded.style !== undefined && group.options.shaded.style !== "") { + fillPath.setAttributeNS(null, "style", group.options.shaded.style); + } + } + + if (group.options.drawPoints.enabled == true) { + var groupTemplate = { + style: group.options.drawPoints.style, + styles: group.options.drawPoints.styles, + size: group.options.drawPoints.size, + className: group.className + }; + DOMutil.drawPoint(x + 0.5 * iconWidth, y, groupTemplate, framework.svgElements, framework.svg); + } } -Line._getStackedYRange = function (intersections, combinedData) { - var key; - var yMin = combinedData[0].y; - var yMax = combinedData[0].y; - for (var i = 0; i < combinedData.length; i++) { - key = combinedData[i].x; - if (intersections[key] === undefined) { - yMin = yMin > combinedData[i].y ? combinedData[i].y : yMin; - yMax = yMax < combinedData[i].y ? combinedData[i].y : yMax; - } - else { - if (combinedData[i].y < 0) { - intersections[key].accumulatedNegative += combinedData[i].y; - } - else { - intersections[key].accumulatedPositive += combinedData[i].y; - } - } - } - for (var xpos in intersections) { - if (intersections.hasOwnProperty(xpos)) { - yMin = yMin > intersections[xpos].accumulatedNegative ? intersections[xpos].accumulatedNegative : yMin; - yMin = yMin > intersections[xpos].accumulatedPositive ? intersections[xpos].accumulatedPositive : yMin; - yMax = yMax < intersections[xpos].accumulatedNegative ? intersections[xpos].accumulatedNegative : yMax; - yMax = yMax < intersections[xpos].accumulatedPositive ? intersections[xpos].accumulatedPositive : yMax; - } - } +Line.drawShading = function (pathArray, group, subPathArray, framework) { + // append shading to the path + if (group.options.shaded.enabled == true) { + var svgHeight = Number(framework.svg.style.height.replace('px','')); + var fillPath = DOMutil.getSVGElement('path', framework.svgElements, framework.svg); + var type = "L"; + if (group.options.interpolation.enabled == true){ + type = "C"; + } + var dFill; + var zero = 0; + if (group.options.shaded.orientation == 'top') { + zero = 0; + } + else if (group.options.shaded.orientation == 'bottom') { + zero = svgHeight; + } + else { + zero = Math.min(Math.max(0, group.zeroPosition), svgHeight); + } + if (group.options.shaded.orientation == 'group' && (subPathArray != null && subPathArray != undefined)) { + dFill = 'M' + pathArray[0][0]+ ","+pathArray[0][1] + " " + + this.serializePath(pathArray,type,false) + + ' L'+ subPathArray[subPathArray.length-1][0]+ "," + subPathArray[subPathArray.length-1][1] + " " + + this.serializePath(subPathArray,type,true) + + subPathArray[0][0]+ ","+subPathArray[0][1] + " Z"; + } + else { + dFill = 'M' + pathArray[0][0]+ ","+pathArray[0][1] + " " + + this.serializePath(pathArray,type,false) + + ' V' + zero + ' H'+ pathArray[0][0] + " Z"; + } - return {min: yMin, max: yMax}; -}; - -/** - * Fill the intersections object with counters of how many datapoints share the same x coordinates - * @param intersections - * @param combinedData - * @private - */ -Line._getDataIntersections = function (intersections, combinedData) { - // get intersections - var coreDistance; - for (var i = 0; i < combinedData.length; i++) { - if (i + 1 < combinedData.length) { - coreDistance = Math.abs(combinedData[i + 1].x - combinedData[i].x); + fillPath.setAttributeNS(null, 'class', group.className + ' vis-fill'); + if (group.options.shaded.style !== undefined) { + fillPath.setAttributeNS(null, 'style', group.options.shaded.style); + } + fillPath.setAttributeNS(null, 'd', dFill); } - if (i > 0) { - coreDistance = Math.min(coreDistance, Math.abs(combinedData[i - 1].x - combinedData[i].x)); - } - if (coreDistance === 0) { - if (intersections[combinedData[i].x] === undefined) { - intersections[combinedData[i].x] = {amount: 0, resolved: 0, accumulatedPositive: 0, accumulatedNegative: 0}; - } - intersections[combinedData[i].x].amount += 1; - } - } -}; - +} /** * draw a line graph @@ -111,53 +114,41 @@ Line._getDataIntersections = function (intersections, combinedData) { * @param dataset * @param group */ -Line.prototype.draw = function (dataset, group, framework) { - if (dataset != null) { - if (dataset.length > 0) { - var path, d; - var svgHeight = Number(framework.svg.style.height.replace('px','')); - path = DOMutil.getSVGElement('path', framework.svgElements, framework.svg); - path.setAttributeNS(null, "class", group.className); - if(group.style !== undefined) { - path.setAttributeNS(null, "style", group.style); - } - - // construct path from dataset - if (group.options.interpolation.enabled == true) { - d = Line._catmullRom(dataset, group); - } - else { - d = Line._linear(dataset); - } - - // append with points for fill and finalize the path - if (group.options.shaded.enabled == true) { - var fillPath = DOMutil.getSVGElement('path', framework.svgElements, framework.svg); - var dFill; - if (group.options.shaded.orientation == 'top') { - dFill = 'M' + dataset[0].x + ',' + 0 + ' ' + d + 'L' + dataset[dataset.length - 1].x + ',' + 0; +Line.draw = function (pathArray, group, framework) { + if (pathArray != null && pathArray != undefined) { + var path = DOMutil.getSVGElement('path', framework.svgElements, framework.svg); + path.setAttributeNS(null, "class", group.className); + if (group.style !== undefined) { + path.setAttributeNS(null, "style", group.style); } - else { - dFill = 'M' + dataset[0].x + ',' + svgHeight + ' ' + d + 'L' + dataset[dataset.length - 1].x + ',' + svgHeight; - } - fillPath.setAttributeNS(null, 'class', group.className + ' vis-fill'); - if(group.options.shaded.style !== undefined) { - fillPath.setAttributeNS(null, 'style', group.options.shaded.style); - } - fillPath.setAttributeNS(null, 'd', dFill); - } - // copy properties to path for drawing. - path.setAttributeNS(null, 'd', 'M' + d); - // draw points - if (group.options.drawPoints.enabled == true) { - Points.draw(dataset, group, framework); - } + var type = "L"; + if (group.options.interpolation.enabled == true){ + type = "C"; + } + // copy properties to path for drawing. + path.setAttributeNS(null, 'd', 'M' + pathArray[0][0]+ ","+pathArray[0][1] + " " + this.serializePath(pathArray,type,false)); } - } }; - +Line.serializePath = function(pathArray,type,inverse){ + if (pathArray.length < 2){ + //Too little data to create a path. + return ""; + } + var d = type; + if (inverse){ + for (var i = pathArray.length-2; i > 0; i--){ + d += pathArray[i][0] + "," + pathArray[i][1] + " "; + } + } + else { + for (var i = 1; i < pathArray.length; i++){ + d += pathArray[i][0] + "," + pathArray[i][1] + " "; + } + } + return d; +} /** * This uses an uniform parametrization of the interpolation algorithm: @@ -166,41 +157,44 @@ Line.prototype.draw = function (dataset, group, framework) { * @returns {string} * @private */ -Line._catmullRomUniform = function(data) { - // catmull rom - var p0, p1, p2, p3, bp1, bp2; - var d = Math.round(data[0].x) + ',' + Math.round(data[0].y) + ' '; - var normalization = 1/6; - var length = data.length; - for (var i = 0; i < length - 1; i++) { +Line._catmullRomUniform = function (data) { + // catmull rom + var p0, p1, p2, p3, bp1, bp2; + var d = []; + d.push( [ Math.round(data[0].screen_x) , Math.round(data[0].screen_y) ]); + var normalization = 1 / 6; + var length = data.length; + for (var i = 0; i < length - 1; i++) { - p0 = (i == 0) ? data[0] : data[i-1]; - p1 = data[i]; - p2 = data[i+1]; - p3 = (i + 2 < length) ? data[i+2] : p2; + p0 = (i == 0) ? data[0] : data[i - 1]; + p1 = data[i]; + p2 = data[i + 1]; + p3 = (i + 2 < length) ? data[i + 2] : p2; - // Catmull-Rom to Cubic Bezier conversion matrix - // 0 1 0 0 - // -1/6 1 1/6 0 - // 0 1/6 1 -1/6 - // 0 0 1 0 + // Catmull-Rom to Cubic Bezier conversion matrix + // 0 1 0 0 + // -1/6 1 1/6 0 + // 0 1/6 1 -1/6 + // 0 0 1 0 - // bp0 = { x: p1.x, y: p1.y }; - bp1 = { x: ((-p0.x + 6*p1.x + p2.x) *normalization), y: ((-p0.y + 6*p1.y + p2.y) *normalization)}; - bp2 = { x: (( p1.x + 6*p2.x - p3.x) *normalization), y: (( p1.y + 6*p2.y - p3.y) *normalization)}; - // bp0 = { x: p2.x, y: p2.y }; + // bp0 = { x: p1.x, y: p1.y }; + bp1 = { + screen_x: ((-p0.screen_x + 6 * p1.screen_x + p2.screen_x) * normalization), + screen_y: ((-p0.screen_y + 6 * p1.screen_y + p2.screen_y) * normalization) + }; + bp2 = { + screen_x: (( p1.screen_x + 6 * p2.screen_x - p3.screen_x) * normalization), + screen_y: (( p1.screen_y + 6 * p2.screen_y - p3.screen_y) * normalization) + }; + // bp0 = { x: p2.x, y: p2.y }; - d += 'C' + - bp1.x + ',' + - bp1.y + ' ' + - bp2.x + ',' + - bp2.y + ' ' + - p2.x + ',' + - p2.y + ' '; - } + d.push( [ bp1.screen_x , bp1.screen_y ]); + d.push( [ bp2.screen_x , bp2.screen_y ]); + d.push( [ p2.screen_x , p2.screen_y ]); + } - return d; + return d; }; /** @@ -214,70 +208,79 @@ Line._catmullRomUniform = function(data) { * @returns {string} * @private */ -Line._catmullRom = function(data, group) { - var alpha = group.options.interpolation.alpha; - if (alpha == 0 || alpha === undefined) { - return this._catmullRomUniform(data); - } - else { - var p0, p1, p2, p3, bp1, bp2, d1,d2,d3, A, B, N, M; - var d3powA, d2powA, d3pow2A, d2pow2A, d1pow2A, d1powA; - var d = Math.round(data[0].x) + ',' + Math.round(data[0].y) + ' '; - var length = data.length; - for (var i = 0; i < length - 1; i++) { - - p0 = (i == 0) ? data[0] : data[i-1]; - p1 = data[i]; - p2 = data[i+1]; - p3 = (i + 2 < length) ? data[i+2] : p2; - - d1 = Math.sqrt(Math.pow(p0.x - p1.x,2) + Math.pow(p0.y - p1.y,2)); - d2 = Math.sqrt(Math.pow(p1.x - p2.x,2) + Math.pow(p1.y - p2.y,2)); - d3 = Math.sqrt(Math.pow(p2.x - p3.x,2) + Math.pow(p2.y - p3.y,2)); - - // Catmull-Rom to Cubic Bezier conversion matrix - - // A = 2d1^2a + 3d1^a * d2^a + d3^2a - // B = 2d3^2a + 3d3^a * d2^a + d2^2a - - // [ 0 1 0 0 ] - // [ -d2^2a /N A/N d1^2a /N 0 ] - // [ 0 d3^2a /M B/M -d2^2a /M ] - // [ 0 0 1 0 ] - - d3powA = Math.pow(d3, alpha); - d3pow2A = Math.pow(d3,2*alpha); - d2powA = Math.pow(d2, alpha); - d2pow2A = Math.pow(d2,2*alpha); - d1powA = Math.pow(d1, alpha); - d1pow2A = Math.pow(d1,2*alpha); - - A = 2*d1pow2A + 3*d1powA * d2powA + d2pow2A; - B = 2*d3pow2A + 3*d3powA * d2powA + d2pow2A; - N = 3*d1powA * (d1powA + d2powA); - if (N > 0) {N = 1 / N;} - M = 3*d3powA * (d3powA + d2powA); - if (M > 0) {M = 1 / M;} - - bp1 = { x: ((-d2pow2A * p0.x + A*p1.x + d1pow2A * p2.x) * N), - y: ((-d2pow2A * p0.y + A*p1.y + d1pow2A * p2.y) * N)}; - - bp2 = { x: (( d3pow2A * p1.x + B*p2.x - d2pow2A * p3.x) * M), - y: (( d3pow2A * p1.y + B*p2.y - d2pow2A * p3.y) * M)}; - - if (bp1.x == 0 && bp1.y == 0) {bp1 = p1;} - if (bp2.x == 0 && bp2.y == 0) {bp2 = p2;} - d += 'C' + - bp1.x + ',' + - bp1.y + ' ' + - bp2.x + ',' + - bp2.y + ' ' + - p2.x + ',' + - p2.y + ' '; +Line._catmullRom = function (data, group) { + var alpha = group.options.interpolation.alpha; + if (alpha == 0 || alpha === undefined) { + return this._catmullRomUniform(data); } + else { + var p0, p1, p2, p3, bp1, bp2, d1, d2, d3, A, B, N, M; + var d3powA, d2powA, d3pow2A, d2pow2A, d1pow2A, d1powA; + var d = []; + d.push( [ Math.round(data[0].screen_x) , Math.round(data[0].screen_y) ]); + var length = data.length; + for (var i = 0; i < length - 1; i++) { - return d; - } + p0 = (i == 0) ? data[0] : data[i - 1]; + p1 = data[i]; + p2 = data[i + 1]; + p3 = (i + 2 < length) ? data[i + 2] : p2; + + d1 = Math.sqrt(Math.pow(p0.screen_x - p1.screen_x, 2) + Math.pow(p0.screen_y - p1.screen_y, 2)); + d2 = Math.sqrt(Math.pow(p1.screen_x - p2.screen_x, 2) + Math.pow(p1.screen_y - p2.screen_y, 2)); + d3 = Math.sqrt(Math.pow(p2.screen_x - p3.screen_x, 2) + Math.pow(p2.screen_y - p3.screen_y, 2)); + + // Catmull-Rom to Cubic Bezier conversion matrix + + // A = 2d1^2a + 3d1^a * d2^a + d3^2a + // B = 2d3^2a + 3d3^a * d2^a + d2^2a + + // [ 0 1 0 0 ] + // [ -d2^2a /N A/N d1^2a /N 0 ] + // [ 0 d3^2a /M B/M -d2^2a /M ] + // [ 0 0 1 0 ] + + d3powA = Math.pow(d3, alpha); + d3pow2A = Math.pow(d3, 2 * alpha); + d2powA = Math.pow(d2, alpha); + d2pow2A = Math.pow(d2, 2 * alpha); + d1powA = Math.pow(d1, alpha); + d1pow2A = Math.pow(d1, 2 * alpha); + + A = 2 * d1pow2A + 3 * d1powA * d2powA + d2pow2A; + B = 2 * d3pow2A + 3 * d3powA * d2powA + d2pow2A; + N = 3 * d1powA * (d1powA + d2powA); + if (N > 0) { + N = 1 / N; + } + M = 3 * d3powA * (d3powA + d2powA); + if (M > 0) { + M = 1 / M; + } + + bp1 = { + screen_x: ((-d2pow2A * p0.screen_x + A * p1.screen_x + d1pow2A * p2.screen_x) * N), + screen_y: ((-d2pow2A * p0.screen_y + A * p1.screen_y + d1pow2A * p2.screen_y) * N) + }; + + bp2 = { + screen_x: (( d3pow2A * p1.screen_x + B * p2.screen_x - d2pow2A * p3.screen_x) * M), + screen_y: (( d3pow2A * p1.screen_y + B * p2.screen_y - d2pow2A * p3.screen_y) * M) + }; + + if (bp1.screen_x == 0 && bp1.screen_y == 0) { + bp1 = p1; + } + if (bp2.screen_x == 0 && bp2.screen_y == 0) { + bp2 = p2; + } + d.push( [ bp1.screen_x , bp1.screen_y ]); + d.push( [ bp2.screen_x , bp2.screen_y ]); + d.push( [ p2.screen_x , p2.screen_y ]); + } + + return d; + } }; /** @@ -286,18 +289,13 @@ Line._catmullRom = function(data, group) { * @returns {string} * @private */ -Line._linear = function(data) { - // linear - var d = ''; - for (var i = 0; i < data.length; i++) { - if (i == 0) { - d += data[i].x + ',' + data[i].y; +Line._linear = function (data) { + // linear + var d = []; + for (var i = 0; i < data.length; i++) { + d.push([ data[i].screen_x , data[i].screen_y ]); } - else { - d += ' ' + data[i].x + ',' + data[i].y; - } - } - return d; + return d; }; module.exports = Line; diff --git a/lib/timeline/component/graph2d_types/points.js b/lib/timeline/component/graph2d_types/points.js index f407fddc5..c6a05a925 100644 --- a/lib/timeline/component/graph2d_types/points.js +++ b/lib/timeline/component/graph2d_types/points.js @@ -1,25 +1,8 @@ var DOMutil = require('../../../DOMutil'); function Points(groupId, options) { - this.groupId = groupId; - this.options = options; } - -Points.prototype.getYRange = function(groupData) { - var yMin = groupData[0].y; - var yMax = groupData[0].y; - for (var j = 0; j < groupData.length; j++) { - yMin = yMin > groupData[j].y ? groupData[j].y : yMin; - yMax = yMax < groupData[j].y ? groupData[j].y : yMax; - } - return {min: yMin, max: yMax, yAxisOrientation: this.options.yAxisOrientation}; -}; - -Points.prototype.draw = function(dataset, group, framework, offset) { - Points.draw(dataset, group, framework, offset); -}; - /** * draw the data points * @@ -31,45 +14,60 @@ Points.prototype.draw = function(dataset, group, framework, offset) { */ Points.draw = function (dataset, group, framework, offset) { offset = offset || 0; - var callback = getCallback(); + var callback = getCallback(framework, group); for (var i = 0; i < dataset.length; i++) { if (!callback) { - // draw the point the simple way. - DOMutil.drawPoint(dataset[i].x + offset, dataset[i].y, getGroupTemplate(), framework.svgElements, framework.svg, dataset[i].label); - } else { - var callbackResult = callback(dataset[i], group, framework); // result might be true, false or an object - if (callbackResult === true || typeof callbackResult === 'object') { - DOMutil.drawPoint(dataset[i].x + offset, dataset[i].y, getGroupTemplate(callbackResult), framework.svgElements, framework.svg, dataset[i].label); - } + // draw the point the simple way. + DOMutil.drawPoint(dataset[i].screen_x + offset, dataset[i].screen_y, getGroupTemplate(group), framework.svgElements, framework.svg, dataset[i].label); } - } - - function getGroupTemplate(callbackResult) { - callbackResult = (typeof callbackResult === 'undefined') ? {} : callbackResult; - return { - style: callbackResult.style || group.options.drawPoints.style, - size: callbackResult.size || group.options.drawPoints.size, - className: callbackResult.className || group.className - }; - } - - function getCallback() { - var callback = undefined; - // check for the graph2d onRender - if (framework.options.drawPoints.onRender && typeof framework.options.drawPoints.onRender == 'function') { - callback = framework.options.drawPoints.onRender; + else { + var callbackResult = callback(dataset[i], group); // result might be true, false or an object + if (callbackResult === true || typeof callbackResult === 'object') { + DOMutil.drawPoint(dataset[i].screen_x + offset, dataset[i].screen_y, getGroupTemplate(group, callbackResult), framework.svgElements, framework.svg, dataset[i].label); } - - // override it with the group onRender if defined - if (group.group.options && group.group.options.drawPoints && group.group.options.drawPoints.onRender && typeof group.group.options.drawPoints.onRender == 'function') { - callback = group.group.options.drawPoints.onRender; - } - - return callback; + } } }; +Points.drawIcon = function (group, x, y, iconWidth, iconHeight, framework) { + var fillHeight = iconHeight * 0.5; + var path, fillPath; + + var outline = DOMutil.getSVGElement("rect", framework.svgElements, framework.svg); + outline.setAttributeNS(null, "x", x); + outline.setAttributeNS(null, "y", y - fillHeight); + outline.setAttributeNS(null, "width", iconWidth); + outline.setAttributeNS(null, "height", 2 * fillHeight); + outline.setAttributeNS(null, "class", "vis-outline"); + + //Don't call callback on icon + DOMutil.drawPoint(x + 0.5 * iconWidth, y, getGroupTemplate(group), framework.svgElements, framework.svg); +}; + +function getGroupTemplate(group, callbackResult) { + callbackResult = (typeof callbackResult === 'undefined') ? {} : callbackResult; + return { + style: callbackResult.style || group.options.drawPoints.style, + styles: callbackResult.styles || group.options.drawPoints.styles, + size: callbackResult.size || group.options.drawPoints.size, + className: callbackResult.className || group.className + }; +} + +function getCallback(framework, group) { + var callback = undefined; + // check for the graph2d onRender + if (framework.options && framework.options.drawPoints && framework.options.drawPoints.onRender && typeof framework.options.drawPoints.onRender == 'function') { + callback = framework.options.drawPoints.onRender; + } + + // override it with the group onRender if defined + if (group.group.options && group.group.options.drawPoints && group.group.options.drawPoints.onRender && typeof group.group.options.drawPoints.onRender == 'function') { + callback = group.group.options.drawPoints.onRender; + } + return callback; +} module.exports = Points; \ No newline at end of file diff --git a/lib/timeline/component/item/PointItem.js b/lib/timeline/component/item/PointItem.js index 8f22ad993..6238e09ef 100644 --- a/lib/timeline/component/item/PointItem.js +++ b/lib/timeline/component/item/PointItem.js @@ -120,13 +120,14 @@ PointItem.prototype.redraw = function() { dom.content.style.marginLeft = 2 * this.props.dot.width + 'px'; //dom.content.style.marginRight = ... + 'px'; // TODO: margin right - dom.dot.style.top = ((this.height - this.props.dot.height) / 2) + 'px'; - dom.dot.style.left = (this.props.dot.width / 2) + 'px'; - // recalculate size this.width = dom.point.offsetWidth; this.height = dom.point.offsetHeight; + // reposition the dot + dom.dot.style.top = ((this.height - this.props.dot.height) / 2) + 'px'; + dom.dot.style.left = (this.props.dot.width / 2) + 'px'; + this.dirty = false; } diff --git a/lib/timeline/optionsGraph2d.js b/lib/timeline/optionsGraph2d.js index c6dc0ddb6..fb8e04557 100644 --- a/lib/timeline/optionsGraph2d.js +++ b/lib/timeline/optionsGraph2d.js @@ -33,12 +33,14 @@ let allOptions = { graphHeight: {string, number}, shaded: { enabled: {boolean}, - orientation: {string:['bottom','top']}, // top, bottom + orientation: {string:['bottom','top','zero','group']}, // top, bottom, zero, group + groupId: {object}, __type__: {boolean,object} }, style: {string:['line','bar','points']}, // line, bar barChart: { width: {number}, + minWidth: {number}, sideBySide: {boolean}, align: {string:['left','center','right']}, __type__: {object} @@ -98,6 +100,7 @@ let allOptions = { }, autoResize: {boolean}, + throttleRedraw: {number}, clickToUse: {boolean}, end: {number, date, string, moment}, format: { @@ -125,8 +128,14 @@ let allOptions = { }, __type__: {object} }, + moment: {'function': 'function'}, height: {string, number}, - hiddenDates: {object, array}, + hiddenDates: { + start: {date, number, string, moment}, + end: {date, number, string, moment}, + repeat: {string}, + __type__: {object, array} + }, locale:{string}, locales:{ __any__: {any}, @@ -134,6 +143,7 @@ let allOptions = { }, max: {date, number, string, moment}, maxHeight: {number, string}, + maxMinorChars: {number}, min: {date, number, string, moment}, minHeight: {number, string}, moveable: {boolean}, @@ -153,6 +163,7 @@ let allOptions = { zoomKey: {string: ['ctrlKey', 'altKey', 'metaKey', '']}, zoomMax: {number}, zoomMin: {number}, + zIndex: {number}, __type__: {object} }; @@ -164,11 +175,12 @@ let configureOptions = { stack:false, shaded: { enabled: false, - orientation: ['top','bottom'] // top, bottom + orientation: ['zero','top','bottom','group'] // zero, top, bottom }, style: ['line','bar','points'], // line, bar barChart: { width: [50,5,100,5], + minWidth: [50,5,100,5], sideBySide: false, align: ['left','center','right'] // left, center, right }, @@ -213,6 +225,7 @@ let configureOptions = { }, autoResize: true, + throttleRedraw: [10, 0, 1000, 10], clickToUse: false, end: '', format: { @@ -242,6 +255,7 @@ let configureOptions = { locale: '', max: '', maxHeight: '', + maxMinorChars: [7, 0, 20, 1], min: '', minHeight: '', moveable:true, @@ -254,8 +268,9 @@ let configureOptions = { zoomable: true, zoomKey: ['ctrlKey', 'altKey', 'metaKey', ''], zoomMax: [315360000000000, 10, 315360000000000, 1], - zoomMin: [10, 10, 315360000000000, 1] + zoomMin: [10, 10, 315360000000000, 1], + zIndex: 0 } }; -export {allOptions, configureOptions}; \ No newline at end of file +export {allOptions, configureOptions}; diff --git a/lib/timeline/optionsTimeline.js b/lib/timeline/optionsTimeline.js index da4c48037..acf93a17e 100644 --- a/lib/timeline/optionsTimeline.js +++ b/lib/timeline/optionsTimeline.js @@ -27,6 +27,7 @@ let allOptions = { //globals : align: {string}, autoResize: {boolean}, + throttleRedraw: {number}, clickToUse: {boolean}, dataAttributes: {string, array}, editable: { @@ -62,9 +63,23 @@ let allOptions = { }, __type__: {object} }, + moment: {'function': 'function'}, groupOrder: {string, 'function': 'function'}, + groupEditable: { + add: {boolean, 'undefined': 'undefined'}, + remove: {boolean, 'undefined': 'undefined'}, + order: {boolean, 'undefined': 'undefined'}, + __type__: {boolean, object} + }, + groupOrderSwap: {'function': 'function'}, height: {string, number}, - hiddenDates: {object, array}, + hiddenDates: { + start: {date, number, string, moment}, + end: {date, number, string, moment}, + repeat: {string}, + __type__: {object, array} + }, + itemsAlwaysDraggable: { boolean: boolean }, locale:{string}, locales:{ __any__: {any}, @@ -81,15 +96,20 @@ let allOptions = { }, max: {date, number, string, moment}, maxHeight: {number, string}, + maxMinorChars: {number}, min: {date, number, string, moment}, minHeight: {number, string}, moveable: {boolean}, multiselect: {boolean}, + multiselectPerGroup: {boolean}, onAdd: {'function': 'function'}, onUpdate: {'function': 'function'}, onMove: {'function': 'function'}, onMoving: {'function': 'function'}, onRemove: {'function': 'function'}, + onAddGroup: {'function': 'function'}, + onMoveGroup: {'function': 'function'}, + onRemoveGroup: {'function': 'function'}, order: {'function': 'function'}, orientation: { axis: {string,'undefined': 'undefined'}, @@ -124,6 +144,7 @@ let configureOptions = { global: { align: ['center', 'left', 'right'], autoResize: true, + throttleRedraw: [10, 0, 1000, 10], clickToUse: false, // dataAttributes: ['all'], // FIXME: can be 'all' or string[] editable: { @@ -157,6 +178,7 @@ let configureOptions = { }, //groupOrder: {string, 'function': 'function'}, + groupsDraggable: false, height: '', //hiddenDates: {object, array}, locale: '', @@ -169,10 +191,12 @@ let configureOptions = { }, max: '', maxHeight: '', + maxMinorChars: [7, 0, 20, 1], min: '', minHeight: '', moveable: false, multiselect: false, + multiselectPerGroup: false, //onAdd: {'function': 'function'}, //onUpdate: {'function': 'function'}, //onMove: {'function': 'function'}, diff --git a/lib/util.js b/lib/util.js index 47960085e..a433075ad 100644 --- a/lib/util.js +++ b/lib/util.js @@ -232,7 +232,12 @@ exports.selectiveDeepExtend = function (props, a, b, allowDeletion = false) { } else if (Array.isArray(b[prop])) { throw new TypeError('Arrays are not supported by deepExtend'); } else { - a[prop] = b[prop]; + if ((b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { + delete a[prop]; + } + else { + a[prop] = b[prop]; + } } } @@ -278,7 +283,12 @@ exports.selectiveNotDeepExtend = function (props, a, b, allowDeletion = false) { a[prop].push(b[prop][i]); } } else { - a[prop] = b[prop]; + if ((b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { + delete a[prop]; + } + else { + a[prop] = b[prop]; + } } } } @@ -319,7 +329,12 @@ exports.deepExtend = function (a, b, protoExtend, allowDeletion) { a[prop].push(b[prop][i]); } } else { - a[prop] = b[prop]; + if ((b[prop] === null) && a[prop] !== undefined && allowDeletion === true) { + delete a[prop]; + } + else { + a[prop] = b[prop]; + } } } } @@ -663,7 +678,7 @@ exports.toArray = function (object) { } return array; -} +}; /** * Update a property in an object @@ -682,6 +697,34 @@ exports.updateProperty = function (object, key, value) { } }; +/** + * Throttle the given function to be only executed once every `wait` milliseconds + * @param {function} fn + * @param {number} wait Time in milliseconds + * @returns {function} Returns the throttled function + */ +exports.throttle = function (fn, wait) { + var timeout = null; + var needExecution = false; + + return function throttled () { + if (!timeout) { + needExecution = false; + fn(); + + timeout = setTimeout(function() { + timeout = null; + if (needExecution) { + throttled(); + } + }, wait) + } + else { + needExecution = true; + } + } +}; + /** * Add and event listener. Works for all browsers * @param {Element} element An html element @@ -1210,6 +1253,23 @@ exports.bridgeObject = function (referenceObject) { } }; +/** + * This method provides a stable sort implementation, very fast for presorted data + * + * @param a the array + * @param a order comparator + * @returns {the array} + */ +exports.insertSort = function (a,compare) { + for (var i = 0; i < a.length; i++) { + var k = a[i]; + for (var j = i; j > 0 && compare(k,a[j - 1])<0; j--) { + a[j] = a[j - 1]; + } + a[j] = k; + } + return a; +} /** * this is used to set the options of subobjects in the options object. A requirement of these subobjects @@ -1218,12 +1278,10 @@ exports.bridgeObject = function (referenceObject) { * @param [object] mergeTarget | this is either this.options or the options used for the groups. * @param [object] options | options * @param [String] option | this is the option key in the options argument - * @private */ -exports.mergeOptions = function (mergeTarget, options, option, allowDeletion = false) { +exports.mergeOptions = function (mergeTarget, options, option, allowDeletion = false, globalOptions = {}) { if (options[option] === null) { - mergeTarget[option] = undefined; - delete mergeTarget[option]; + mergeTarget[option] = Object.create(globalOptions[option]); } else { if (options[option] !== undefined) { @@ -1250,13 +1308,13 @@ exports.mergeOptions = function (mergeTarget, options, option, allowDeletion = f * this function will then iterate in both directions over this sorted list to find all visible items. * * @param {Item[]} orderedItems | Items ordered by start - * @param {function} searchFunction | -1 is lower, 0 is found, 1 is higher + * @param {function} comparator | -1 is lower, 0 is equal, 1 is higher * @param {String} field * @param {String} field2 * @returns {number} * @private */ -exports.binarySearchCustom = function (orderedItems, searchFunction, field, field2) { +exports.binarySearchCustom = function (orderedItems, comparator, field, field2) { var maxIterations = 10000; var iteration = 0; var low = 0; @@ -1268,7 +1326,7 @@ exports.binarySearchCustom = function (orderedItems, searchFunction, field, fiel var item = orderedItems[middle]; var value = (field2 === undefined) ? item[field] : item[field][field2]; - var searchResult = searchFunction(value); + var searchResult = comparator(value); if (searchResult == 0) { // jihaa, found a visible item! return middle; } @@ -1294,16 +1352,21 @@ exports.binarySearchCustom = function (orderedItems, searchFunction, field, fiel * @param {{start: number, end: number}} target * @param {String} field * @param {String} sidePreference 'before' or 'after' + * @param {function} comparator an optional comparator, returning -1,0,1 for <,==,>. * @returns {number} * @private */ -exports.binarySearchValue = function (orderedItems, target, field, sidePreference) { +exports.binarySearchValue = function (orderedItems, target, field, sidePreference, comparator) { var maxIterations = 10000; var iteration = 0; var low = 0; var high = orderedItems.length - 1; var prevValue, value, nextValue, middle; + var comparator = comparator != undefined ? comparator : function (a, b) { + return a == b ? 0 : a < b ? -1 : 1 + }; + while (low <= high && iteration < maxIterations) { // get a new guess middle = Math.floor(0.5 * (high + low)); @@ -1311,17 +1374,17 @@ exports.binarySearchValue = function (orderedItems, target, field, sidePreferenc value = orderedItems[middle][field]; nextValue = orderedItems[Math.min(orderedItems.length - 1, middle + 1)][field]; - if (value == target) { // we found the target + if (comparator(value, target) == 0) { // we found the target return middle; } - else if (prevValue < target && value > target) { // target is in between of the previous and the current + else if (comparator(prevValue, target) < 0 && comparator(value, target) > 0) { // target is in between of the previous and the current return sidePreference == 'before' ? Math.max(0, middle - 1) : middle; } - else if (value < target && nextValue > target) { // target is in between of the current and the next + else if (comparator(value, target) < 0 && comparator(nextValue, target) > 0) { // target is in between of the current and the next return sidePreference == 'before' ? middle : Math.min(orderedItems.length - 1, middle + 1); } else { // didnt find the target, we need to change our boundaries. - if (value < target) { // it is too small --> increase low + if (comparator(value, target) < 0) { // it is too small --> increase low low = middle + 1; } else { // it is too big --> decrease high diff --git a/misc/how_to_publish.md b/misc/how_to_publish.md index 676556ae6..38492d76c 100644 --- a/misc/how_to_publish.md +++ b/misc/how_to_publish.md @@ -5,7 +5,7 @@ This document describes how to publish vis.js. ## Build -- Change the version number of the library in both `package.json` and `bower.json`. +- Change the version number of the library in `package.json`. - Open `HISTORY.md`, write down the changes, version number, and release date. - Build the library by running: @@ -76,6 +76,8 @@ This generates the vis.js library in the folder `./dist`. - Update the library version number in the index.html page. +- Update the CDN links at the download section of index.html AND the CDN link at the top. (replace all) + - Commit the changes in the `gh-pages` branch. diff --git a/package.json b/package.json index 52c761822..c33714425 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vis", - "version": "4.5.0", + "version": "4.14.0", "description": "A dynamic, browser-based visualization library.", "homepage": "http://visjs.org/", "license": "(Apache-2.0 OR MIT)", @@ -21,22 +21,21 @@ "network", "browser" ], - "main": "./index", + "main": "./dist/vis.js", "scripts": { "test": "mocha", "build": "gulp", "watch": "gulp watch", "watch-dev": "gulp watch --bundle" }, - "dependencies": { - "emitter-component": "^1.1.1", - "hammerjs": "^2.0.4", - "keycharm": "^0.2.0", - "uuid": "^2.0.1", - "moment": "^2.10.2", - "propagating-hammerjs": "^1.4.3" - }, + "dependencies": {}, "devDependencies": { + "emitter-component": "^1.1.1", + "hammerjs": "^2.0.6", + "keycharm": "^0.2.0", + "moment": "^2.10.2", + "propagating-hammerjs": "^1.4.5", + "uuid": "^2.0.1", "babel": "^5.1.11", "babel-loader": "^5.0.0", "babelify": "^6.0.2", @@ -52,8 +51,5 @@ "uglify-js": "^2.4.20", "webpack": "^1.8.5", "yargs": "^3.7.2" - }, - "browserify": { - "transform": ["babelify"] } } diff --git a/test/DataView.test.js b/test/DataView.test.js index 8a2675286..4d3c5f97e 100644 --- a/test/DataView.test.js +++ b/test/DataView.test.js @@ -177,15 +177,27 @@ describe('DataView', function () { // make a change not affecting the DataView data.update({id: 1, title: 'Item 1 (changed)'}); assert.deepEqual(dataUpdates, [ - ['update', {items: [1], data: [{id: 1, title: 'Item 1 (changed)'}]}] + ['update', { + items: [1], + data: [{id: 1, title: 'Item 1 (changed)'}], + oldData: [{"group": 1, "id": 1, "title": "Item 1"}] + }] ]); assert.deepEqual(viewUpdates, []); // make a change affecting the DataView data.update({id: 2, title: 'Item 2 (changed)'}); assert.deepEqual(dataUpdates, [ - ['update', {items: [1], data: [{id: 1, title: 'Item 1 (changed)'}]}], - ['update', {items: [2], data: [{id: 2, title: 'Item 2 (changed)'}]}] + ['update', { + items: [1], + data: [{id: 1, title: 'Item 1 (changed)'}], + oldData: [{"group": 1, "id": 1, "title": "Item 1"}] + }], + ['update', { + items: [2], + data: [{id: 2, title: 'Item 2 (changed)'}], + oldData: [{"group": 2, "id": 2, "title": "Item 2"}] + }] ]); assert.deepEqual(viewUpdates, [ ['update', {items: [2], data: [{id: 2, title: 'Item 2 (changed)'}]}] diff --git a/test/networkTest.html b/test/networkTest.html index 7a948c12b..a7ec8161e 100644 --- a/test/networkTest.html +++ b/test/networkTest.html @@ -1,148 +1,68 @@ - + - Network | Hierarchical layout - + + JS Bin + + - - - - - + +

Network Test

+
+ -
- -
- -

- + \ No newline at end of file diff --git a/test/network_unittests.html b/test/network_unittests.html index a6a45bf35..2d3e2eaab 100644 --- a/test/network_unittests.html +++ b/test/network_unittests.html @@ -176,36 +176,51 @@ - var amountOfOptionChecks = 200; - var optionCheckTime = 150; + var amountOfOptionChecks = 50; var optionsThreshold = 0.8; - function checkOptions(i) { -// console.log('checking Options iteration:',i) - var allOptions = vis.network.allOptions.allOptions; - var testOptions = {}; - constructOptions(allOptions, testOptions); - var failed = setTimeout(function() {console.error("FAILED",JSON.stringify(testOptions,null,4))}, 0.9*optionCheckTime); - var counter = 0; - drawQuick(); - network.on("afterDrawing", function() { - counter++; - if (counter > 2) { - counter = 0; - network.off('afterDrawing'); + var optionGlobalCount = 0; + function checkOptions() { + optionGlobalCount++; + if (optionGlobalCount == amountOfOptionChecks) { + checkMethods(); + } + else { + var allOptions = vis.network.allOptions.allOptions; + var testOptions = {}; + constructOptions(allOptions, testOptions); + if (testOptions.physics === undefined) {testOptions.physics = {};} + if (testOptions.layout === undefined) {testOptions.layout = {};} + testOptions.physics.enabled = true; + testOptions.layout.improvedLayout = false; + var failed = setTimeout(function () { + console.error("FAILED", JSON.stringify(testOptions, null, 4)) + }, 500); + var counter = 0; + drawQuick(); + network.on("afterDrawing", function () { + counter++; + if (counter > 2) { + counter = 0; + network.off('afterDrawing'); + clearTimeout(failed); + network.destroy(); + } + }) + network.on("stabilized", function () { clearTimeout(failed); - } - }) - network.on("stabilized", function() { - clearTimeout(failed); - }); - network.on("destroy", function() { - clearTimeout(failed); - }) - network.setOptions(testOptions); + network.destroy(); + }); + network.once("destroy", function () { + clearTimeout(failed); + setTimeout(checkOptions, 100); + }) + console.log("now testing:",testOptions) + + network.setOptions(testOptions); + } } function constructOptions(allOptions, testOptions) { - for (var option in allOptions) { if (Math.random() < optionsThreshold) { if (option !== "__type__" && option !== '__any__' && option !== 'locales' && option !== 'image' && option !== 'id') { @@ -254,10 +269,11 @@ } - for (var i = 0; i < amountOfOptionChecks; i++) { - setTimeout(checkOptions.bind(this,i), i*optionCheckTime); - } - setTimeout(checkMethods, amountOfOptionChecks*optionCheckTime); + checkOptions(); +// for (var i = 0; i < amountOfOptionChecks; i++) { +// setTimeout(checkOptions.bind(this,i), i*optionCheckTime); +// } +// setTimeout(checkMethods, amountOfOptionChecks*optionCheckTime); \ No newline at end of file diff --git a/test/timeline.html b/test/timeline.html index 27e5ed25b..9cc1f384b 100644 --- a/test/timeline.html +++ b/test/timeline.html @@ -216,6 +216,8 @@ items.on('update', console.log.bind(console)); items.on('remove', console.log.bind(console)); + +// timeline.setOptions({timeAxis:{scale: 'minute', step: 5}}) \ No newline at end of file diff --git a/test/timeline_groups.html b/test/timeline_groups.html index aac1c2ca2..4df1b4330 100644 --- a/test/timeline_groups.html +++ b/test/timeline_groups.html @@ -71,7 +71,9 @@ } // create a dataset with items - var items = new vis.DataSet(); + var items = new vis.DataSet({ + type: {start: 'Moment', end: 'Moment'} + }); for (var i = 0; i < itemCount; i++) { var start = now.clone().add(Math.random() * 200, 'hours'); var end = Math.random() > 0.5 ? start.clone().add(24, 'hours') : undefined; @@ -117,6 +119,7 @@ }, onMove: function (item, callback) { + console.log('onMove', item) if (confirm('Do you really want to move the item to\n' + 'start: ' + item.start + '\n' + 'end: ' + item.end + '?')) { From 823177e877675a11fdf46ba200a23b7c25a0321d Mon Sep 17 00:00:00 2001 From: pblasquez Date: Wed, 17 Feb 2016 13:06:46 -0800 Subject: [PATCH 14/29] Update graph.php --- html/graph.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/html/graph.php b/html/graph.php index a3a7db529..0eff18fc3 100644 --- a/html/graph.php +++ b/html/graph.php @@ -40,7 +40,7 @@ require_once '../includes/dbFacile.php'; require_once '../includes/rewrites.php'; require_once 'includes/functions.inc.php'; require_once '../includes/rrdtool.inc.php'; -if($config['allow_unauth_graphs'] == 0) { +if($config['allow_unauth_graphs'] != true) { require_once 'includes/authenticate.inc.php'; } require 'includes/graphs/graph.inc.php'; From 591c40a6f4785327a10a47bf8b4ba7a451521f4e Mon Sep 17 00:00:00 2001 From: pblasquez Date: Wed, 17 Feb 2016 13:10:07 -0800 Subject: [PATCH 15/29] Update defaults.inc.php --- includes/defaults.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/defaults.inc.php b/includes/defaults.inc.php index d12854016..2f01dc6bc 100644 --- a/includes/defaults.inc.php +++ b/includes/defaults.inc.php @@ -568,7 +568,7 @@ $config['irc_alert'] = false; $config['irc_alert_utf8'] = false; // Authentication -$config['allow_unauth_graphs'] = 0; +$config['allow_unauth_graphs'] = false; // Allow graphs to be viewed by anyone $config['allow_unauth_graphs_cidr'] = array(); // Allow graphs to be viewed without authorisation from certain IP ranges From 22a81e04243b71c3f5c303431eaade00d318e0bd Mon Sep 17 00:00:00 2001 From: Eldon Koyle Date: Wed, 17 Feb 2016 15:04:50 -0700 Subject: [PATCH 16/29] Use unique id for bootgrid table in alert widget so there can be more than one --- html/includes/common/alerts.inc.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/html/includes/common/alerts.inc.php b/html/includes/common/alerts.inc.php index 44495f347..5aac00081 100644 --- a/html/includes/common/alerts.inc.php +++ b/html/includes/common/alerts.inc.php @@ -148,6 +148,7 @@ else { $widget_settings['title'] = $title; $group = $widget_settings['group']; + $widget_id = (int)$_POST['id']; $common_output[] = '
@@ -156,7 +157,7 @@ else {
- +
@@ -171,7 +172,7 @@ else {
Status