From c2f50e258a9437850ab7a107cd920c65755e3a5f Mon Sep 17 00:00:00 2001 From: epriestley Date: Fri, 23 Mar 2012 17:11:15 -0700 Subject: [PATCH] Render pretty graphical traces for commit branches, etc Summary: I AM A WIZARD Test Plan: BEHOLD Reviewers: btrahan Reviewed By: btrahan CC: aran, epriestley Maniphest Tasks: T961 Differential Revision: https://secure.phabricator.com/D2007 --- src/__celerity_resource_map__.php | 58 +++--- .../history/DiffusionHistoryController.php | 10 + .../DiffusionRepositoryController.php | 3 + .../history/base/DiffusionHistoryQuery.php | 15 ++ .../history/git/DiffusionGitHistoryQuery.php | 26 ++- .../diffusion/query/history/git/__init__.php | 2 + .../DiffusionMercurialHistoryQuery.php | 50 ++++- .../DiffusionHistoryTableView.php | 179 ++++++++++++++++++ .../diffusion/view/historytable/__init__.php | 3 + webroot/rsrc/css/aphront/table-view.css | 10 + .../diffusion/behavior-commit-graph.js | 137 ++++++++++++++ 11 files changed, 456 insertions(+), 37 deletions(-) create mode 100644 webroot/rsrc/js/application/diffusion/behavior-commit-graph.js diff --git a/src/__celerity_resource_map__.php b/src/__celerity_resource_map__.php index e2699e5b08..3e8040c002 100644 --- a/src/__celerity_resource_map__.php +++ b/src/__celerity_resource_map__.php @@ -135,7 +135,7 @@ celerity_register_resource_map(array( ), 'aphront-table-view-css' => array( - 'uri' => '/res/3ff30c4f/rsrc/css/aphront/table-view.css', + 'uri' => '/res/cbc7ab3a/rsrc/css/aphront/table-view.css', 'type' => 'css', 'requires' => array( @@ -642,6 +642,18 @@ celerity_register_resource_map(array( ), 'disk' => '/rsrc/js/application/differential/behavior-show-more.js', ), + 'javelin-behavior-diffusion-commit-graph' => + array( + 'uri' => '/res/cfe336e8/rsrc/js/application/diffusion/behavior-commit-graph.js', + 'type' => 'js', + 'requires' => + array( + 0 => 'javelin-behavior', + 1 => 'javelin-dom', + 2 => 'javelin-stratcom', + ), + 'disk' => '/rsrc/js/application/diffusion/behavior-commit-graph.js', + ), 'javelin-behavior-diffusion-jump-to' => array( 'uri' => '/res/7c42e1ba/rsrc/js/application/diffusion/behavior-jump-to.js', @@ -1692,7 +1704,7 @@ celerity_register_resource_map(array( ), 'phabricator-remarkup-css' => array( - 'uri' => '/res/11f89984/rsrc/css/core/remarkup.css', + 'uri' => '/res/ff60c68a/rsrc/css/core/remarkup.css', 'type' => 'css', 'requires' => array( @@ -1968,7 +1980,7 @@ celerity_register_resource_map(array( ), array( 'packages' => array( - '66f447f1' => + 'c02b2ef0' => array( 'name' => 'core.pkg.css', 'symbols' => @@ -1993,7 +2005,7 @@ celerity_register_resource_map(array( 17 => 'aphront-pager-view-css', 18 => 'phabricator-transaction-view-css', ), - 'uri' => '/res/pkg/66f447f1/core.pkg.css', + 'uri' => '/res/pkg/c02b2ef0/core.pkg.css', 'type' => 'css', ), '21d01ed8' => @@ -2140,17 +2152,17 @@ celerity_register_resource_map(array( 'reverse' => array( 'aphront-attached-file-view-css' => '31583232', - 'aphront-crumbs-view-css' => '66f447f1', - 'aphront-dialog-view-css' => '66f447f1', - 'aphront-form-view-css' => '66f447f1', + 'aphront-crumbs-view-css' => 'c02b2ef0', + 'aphront-dialog-view-css' => 'c02b2ef0', + 'aphront-form-view-css' => 'c02b2ef0', 'aphront-headsup-action-list-view-css' => '551249fc', - 'aphront-list-filter-view-css' => '66f447f1', - 'aphront-pager-view-css' => '66f447f1', - 'aphront-panel-view-css' => '66f447f1', - 'aphront-side-nav-view-css' => '66f447f1', - 'aphront-table-view-css' => '66f447f1', - 'aphront-tokenizer-control-css' => '66f447f1', - 'aphront-typeahead-control-css' => '66f447f1', + 'aphront-list-filter-view-css' => 'c02b2ef0', + 'aphront-pager-view-css' => 'c02b2ef0', + 'aphront-panel-view-css' => 'c02b2ef0', + 'aphront-side-nav-view-css' => 'c02b2ef0', + 'aphront-table-view-css' => 'c02b2ef0', + 'aphront-tokenizer-control-css' => 'c02b2ef0', + 'aphront-typeahead-control-css' => 'c02b2ef0', 'differential-changeset-view-css' => '551249fc', 'differential-core-view-css' => '551249fc', 'differential-inline-comment-editor' => '9b256876', @@ -2208,23 +2220,23 @@ celerity_register_resource_map(array( 'maniphest-task-detail-css' => '31583232', 'maniphest-task-summary-css' => '31583232', 'maniphest-transaction-detail-css' => '31583232', - 'phabricator-app-buttons-css' => '66f447f1', + 'phabricator-app-buttons-css' => 'c02b2ef0', 'phabricator-content-source-view-css' => '551249fc', - 'phabricator-core-buttons-css' => '66f447f1', - 'phabricator-core-css' => '66f447f1', - 'phabricator-directory-css' => '66f447f1', + 'phabricator-core-buttons-css' => 'c02b2ef0', + 'phabricator-core-css' => 'c02b2ef0', + 'phabricator-directory-css' => 'c02b2ef0', 'phabricator-drag-and-drop-file-upload' => '9b256876', 'phabricator-dropdown-menu' => '21d01ed8', - 'phabricator-jump-nav' => '66f447f1', + 'phabricator-jump-nav' => 'c02b2ef0', 'phabricator-keyboard-shortcut' => '21d01ed8', 'phabricator-keyboard-shortcut-manager' => '21d01ed8', 'phabricator-menu-item' => '21d01ed8', 'phabricator-object-selector-css' => '551249fc', 'phabricator-paste-file-upload' => '21d01ed8', - 'phabricator-remarkup-css' => '66f447f1', + 'phabricator-remarkup-css' => 'c02b2ef0', 'phabricator-shaped-request' => '9b256876', - 'phabricator-standard-page-view' => '66f447f1', - 'phabricator-transaction-view-css' => '66f447f1', - 'syntax-highlighting-css' => '66f447f1', + 'phabricator-standard-page-view' => 'c02b2ef0', + 'phabricator-transaction-view-css' => 'c02b2ef0', + 'syntax-highlighting-css' => 'c02b2ef0', ), )); diff --git a/src/applications/diffusion/controller/history/DiffusionHistoryController.php b/src/applications/diffusion/controller/history/DiffusionHistoryController.php index d610b2a3e0..5c7b31bd83 100644 --- a/src/applications/diffusion/controller/history/DiffusionHistoryController.php +++ b/src/applications/diffusion/controller/history/DiffusionHistoryController.php @@ -35,6 +35,11 @@ final class DiffusionHistoryController extends DiffusionController { $history_query->needChildChanges(true); } + $show_graph = !strlen($drequest->getPath()); + if ($show_graph) { + $history_query->needParents(true); + } + $history = $history_query->loadHistory(); $pager = new AphrontPagerView(); @@ -81,6 +86,11 @@ final class DiffusionHistoryController extends DiffusionController { $handles = id(new PhabricatorObjectHandleData($phids))->loadHandles(); $history_table->setHandles($handles); + if ($show_graph) { + $history_table->setParents($history_query->getParents()); + $history_table->setIsHead($offset == 0); + } + $history_panel = new AphrontPanelView(); $history_panel->setHeader('History'); $history_panel->addButton($button); diff --git a/src/applications/diffusion/controller/repository/DiffusionRepositoryController.php b/src/applications/diffusion/controller/repository/DiffusionRepositoryController.php index 4e8271dd34..467275455c 100644 --- a/src/applications/diffusion/controller/repository/DiffusionRepositoryController.php +++ b/src/applications/diffusion/controller/repository/DiffusionRepositoryController.php @@ -31,6 +31,7 @@ final class DiffusionRepositoryController extends DiffusionController { $history_query = DiffusionHistoryQuery::newFromDiffusionRequest( $drequest); $history_query->setLimit(15); + $history_query->needParents(true); $history = $history_query->loadHistory(); $browse_query = DiffusionBrowseQuery::newFromDiffusionRequest($drequest); @@ -63,6 +64,8 @@ final class DiffusionRepositoryController extends DiffusionController { $history_table->setDiffusionRequest($drequest); $history_table->setHandles($handles); $history_table->setHistory($history); + $history_table->setParents($history_query->getParents()); + $history_table->setIsHead(true); $callsign = $drequest->getRepository()->getCallsign(); $all = phutil_render_tag( diff --git a/src/applications/diffusion/query/history/base/DiffusionHistoryQuery.php b/src/applications/diffusion/query/history/base/DiffusionHistoryQuery.php index febdff1b17..865cc3cffc 100644 --- a/src/applications/diffusion/query/history/base/DiffusionHistoryQuery.php +++ b/src/applications/diffusion/query/history/base/DiffusionHistoryQuery.php @@ -23,6 +23,9 @@ abstract class DiffusionHistoryQuery extends DiffusionQuery { protected $needDirectChanges; protected $needChildChanges; + protected $needParents; + + protected $parents = array(); final public static function newFromDiffusionRequest( DiffusionRequest $request) { @@ -40,6 +43,18 @@ abstract class DiffusionHistoryQuery extends DiffusionQuery { return $this; } + final public function needParents($parents) { + $this->needParents = $parents; + return $this; + } + + final public function getParents() { + if (!$this->needParents) { + throw new Exception('Specify needParents() before calling getParents()!'); + } + return $this->parents; + } + final public function loadHistory() { return $this->executeQuery(); } diff --git a/src/applications/diffusion/query/history/git/DiffusionGitHistoryQuery.php b/src/applications/diffusion/query/history/git/DiffusionGitHistoryQuery.php index 5d3fe73499..afad2f3dba 100644 --- a/src/applications/diffusion/query/history/git/DiffusionGitHistoryQuery.php +++ b/src/applications/diffusion/query/history/git/DiffusionGitHistoryQuery.php @@ -1,7 +1,7 @@ getOffset(), $this->getLimit(), + '%H:%P', $commit_hash, - $path); + // Git omits merge commits if the path is provided, even if it is empty. + (strlen($path) ? csprintf('%s', $path) : '')); - $hashes = explode("\n", $stdout); - $hashes = array_filter($hashes); + $hash_list = array(); + $parent_map = array(); - return $this->loadHistoryForCommitIdentifiers($hashes); + $lines = explode("\n", trim($stdout)); + foreach ($lines as $line) { + list($hash, $parents) = explode(":", $line); + $hash_list[] = $hash; + $parent_map[$hash] = preg_split('/\s+/', $parents); + } + + $this->parents = $parent_map; + + return $this->loadHistoryForCommitIdentifiers($hash_list); } } diff --git a/src/applications/diffusion/query/history/git/__init__.php b/src/applications/diffusion/query/history/git/__init__.php index 948d796aa8..e505889020 100644 --- a/src/applications/diffusion/query/history/git/__init__.php +++ b/src/applications/diffusion/query/history/git/__init__.php @@ -8,5 +8,7 @@ phutil_require_module('phabricator', 'applications/diffusion/query/history/base'); +phutil_require_module('phutil', 'xsprintf/csprintf'); + phutil_require_source('DiffusionGitHistoryQuery.php'); diff --git a/src/applications/diffusion/query/history/mercurial/DiffusionMercurialHistoryQuery.php b/src/applications/diffusion/query/history/mercurial/DiffusionMercurialHistoryQuery.php index 88bc66613f..1cd3d0b4d2 100644 --- a/src/applications/diffusion/query/history/mercurial/DiffusionMercurialHistoryQuery.php +++ b/src/applications/diffusion/query/history/mercurial/DiffusionMercurialHistoryQuery.php @@ -33,18 +33,56 @@ final class DiffusionMercurialHistoryQuery extends DiffusionHistoryQuery { $default_path = ''; list($stdout) = $repository->execxLocalCommand( - 'log --template %s --limit %d --branch %s --rev %s:0 -- %s', - '{node}\\n', + 'log --debug --template %s --limit %d --branch %s --rev %s:0 -- %s', + '{node};{parents}\\n', ($this->getOffset() + $this->getLimit()), // No '--skip' in Mercurial. $drequest->getBranch(), $commit_hash, nonempty(ltrim($path, '/'), $default_path)); - $hashes = explode("\n", $stdout); - $hashes = array_filter($hashes); - $hashes = array_slice($hashes, $this->getOffset()); + $lines = explode("\n", trim($stdout)); + $lines = array_slice($lines, $this->getOffset()); - return $this->loadHistoryForCommitIdentifiers($hashes); + $hash_list = array(); + $parent_map = array(); + + $last = null; + foreach (array_reverse($lines) as $line) { + list($hash, $parents) = explode(';', $line); + $parents = trim($parents); + if (!$parents) { + if ($last === null) { + $parent_map[$hash] = array('...'); + } else { + $parent_map[$hash] = array($last); + } + } else { + $parents = preg_split('/\s+/', $parents); + foreach ($parents as $parent) { + list($plocal, $phash) = explode(':', $parent); + if (!preg_match('/^0+$/', $phash)) { + $parent_map[$hash][] = $phash; + } + } + // This may happen for the zeroth commit in repository, both hashes + // are "000000000...". + if (empty($parent_map[$hash])) { + $parent_map[$hash] = array('...'); + } + } + + // The rendering code expects the first commit to be "mainline", like + // Git. Flip the order so it does the right thing. + $parent_map[$hash] = array_reverse($parent_map[$hash]); + + $hash_list[] = $hash; + $last = $hash; + } + + $hash_list = array_reverse($hash_list); + $this->parents = $parent_map; + + return $this->loadHistoryForCommitIdentifiers($hash_list); } } diff --git a/src/applications/diffusion/view/historytable/DiffusionHistoryTableView.php b/src/applications/diffusion/view/historytable/DiffusionHistoryTableView.php index 174344bcc1..14e09dafcf 100644 --- a/src/applications/diffusion/view/historytable/DiffusionHistoryTableView.php +++ b/src/applications/diffusion/view/historytable/DiffusionHistoryTableView.php @@ -20,6 +20,8 @@ final class DiffusionHistoryTableView extends DiffusionView { private $history; private $handles = array(); + private $isHead; + private $parents; public function setHistory(array $history) { $this->history = $history; @@ -44,12 +46,28 @@ final class DiffusionHistoryTableView extends DiffusionView { return array_keys($phids); } + public function setParents(array $parents) { + $this->parents = $parents; + return $this; + } + + public function setIsHead($is_head) { + $this->isHead = $is_head; + return $this; + } + public function render() { $drequest = $this->getDiffusionRequest(); $handles = $this->handles; + $graph = null; + if ($this->parents) { + $graph = $this->renderGraph(); + } + $rows = array(); + $ii = 0; foreach ($this->history as $history) { $epoch = $history->getEpoch(); @@ -79,6 +97,7 @@ final class DiffusionHistoryTableView extends DiffusionView { array( 'commit' => $history->getCommitIdentifier(), )), + $graph ? $graph[$ii++] : null, self::linkCommit( $drequest->getRepository(), $history->getCommitIdentifier()), @@ -100,6 +119,7 @@ final class DiffusionHistoryTableView extends DiffusionView { $view->setHeaders( array( 'Browse', + '', 'Commit', 'Change', 'Date', @@ -110,6 +130,7 @@ final class DiffusionHistoryTableView extends DiffusionView { $view->setColumnClasses( array( '', + 'threads', 'n', '', '', @@ -117,7 +138,165 @@ final class DiffusionHistoryTableView extends DiffusionView { '', 'wide', )); + $view->setColumnVisibility( + array( + true, + $graph ? true : false, + )); return $view->render(); } + /** + * Draw a merge/branch graph from the parent revision data. We're basically + * building up a bunch of strings like this: + * + * ^ + * |^ + * o| + * |o + * o + * + * ...which form an ASCII representation of the graph we eventaully want to + * draw. + * + * NOTE: The actual implementation is black magic. + */ + private function renderGraph() { + + // This keeps our accumulated information about each line of the + // merge/branch graph. + $graph = array(); + + // This holds the next commit we're looking for in each column of the + // graph. + $threads = array(); + + // This is the largest number of columns any row has, i.e. the width of + // the graph. + $count = 0; + + foreach ($this->history as $key => $history) { + $joins = array(); + $splits = array(); + + $parent_list = $this->parents[$history->getCommitIdentifier()]; + + // Look for some thread which has this commit as the next commit. If + // we find one, this commit goes on that thread. Otherwise, this commit + // goes on a new thread. + + $line = ''; + $found = false; + $pos = count($threads); + for ($n = 0; $n < $count; $n++) { + if (empty($threads[$n])) { + $line .= ' '; + continue; + } + + if ($threads[$n] == $history->getCommitIdentifier()) { + if ($found) { + $line .= ' '; + $joins[] = $n; + unset($threads[$n]); + } else { + $line .= 'o'; + $found = true; + $pos = $n; + } + } else { + + // We render a "|" for any threads which have a commit that we haven't + // seen yet, this is later drawn as a vertical line. + $line .= '|'; + } + } + + // If we didn't find the thread this commit goes on, start a new thread. + // We use "o" to mark the commit for the rendering engine, or "^" to + // indicate that there's nothing after it so the line from the commit + // upward should not be drawn. + + if (!$found) { + if ($this->isHead) { + $line .= '^'; + } else { + $line .= 'o'; + foreach ($graph as $k => $meta) { + // Go back across all the lines we've already drawn and add a + // "|" to the end, since this is connected to some future commit + // we don't know about. + for ($jj = strlen($meta['line']); $jj <= $count; $jj++) { + $graph[$k]['line'] .= '|'; + } + } + } + } + + // Update the next commit on this thread to the commit's first parent. + // This might have the effect of making a new thread. + $threads[$pos] = head($parent_list); + + // If we made a new thread, increase the thread count. + $count = max($pos + 1, $count); + + // Now, deal with splits (merges). I picked this terms opposite to the + // underlying repository term to confuse you. + foreach (array_slice($parent_list, 1) as $parent) { + $found = false; + + // Try to find the other parent(s) in our existing threads. If we find + // them, split to that thread. + + foreach ($threads as $n => $thread_commit) { + if ($thread_commit == $parent) { + $found = true; + $splits[] = $n; + } + } + + // If we didn't find the parent, we don't know about it yet. Find the + // first free thread and add it as the "next" commit in that thread. + // This might create a new thread. + + if (!$found) { + for ($n = 0; $n < $count; $n++) { + if (empty($threads[$n])) { + break; + } + } + $threads[$n] = $parent; + $splits[] = $n; + $count = max($n + 1, $count); + } + } + + $graph[] = array( + 'line' => $line, + 'split' => $splits, + 'join' => $joins, + ); + } + + // Render into tags for the behavior. + + foreach ($graph as $k => $meta) { + $graph[$k] = javelin_render_tag( + 'div', + array( + 'sigil' => 'commit-graph', + 'meta' => $meta, + ), + ''); + } + + Javelin::initBehavior( + 'diffusion-commit-graph', + array( + 'count' => $count, + )); + + return $graph; + } + } diff --git a/src/applications/diffusion/view/historytable/__init__.php b/src/applications/diffusion/view/historytable/__init__.php index 46ed27ee07..ae37b385fb 100644 --- a/src/applications/diffusion/view/historytable/__init__.php +++ b/src/applications/diffusion/view/historytable/__init__.php @@ -7,9 +7,12 @@ phutil_require_module('phabricator', 'applications/diffusion/view/base'); +phutil_require_module('phabricator', 'infrastructure/javelin/api'); +phutil_require_module('phabricator', 'infrastructure/javelin/markup'); phutil_require_module('phabricator', 'view/control/table'); phutil_require_module('phutil', 'markup'); +phutil_require_module('phutil', 'utils'); phutil_require_source('DiffusionHistoryTableView.php'); diff --git a/webroot/rsrc/css/aphront/table-view.css b/webroot/rsrc/css/aphront/table-view.css index c014f05f48..5ff328f6ee 100644 --- a/webroot/rsrc/css/aphront/table-view.css +++ b/webroot/rsrc/css/aphront/table-view.css @@ -127,6 +127,16 @@ span.single-display-line-content { max-height: 64px; } +.aphront-table-view td.threads { + font-family: monospace; + white-space: pre; + padding: 0; +} + +.aphront-table-view td.threads canvas { + display: block; +} + .aphront-table-view th.aphront-table-view-sortable { padding: 0; } diff --git a/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js b/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js new file mode 100644 index 0000000000..99968e00cb --- /dev/null +++ b/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js @@ -0,0 +1,137 @@ +/** + * @provides javelin-behavior-diffusion-commit-graph + * @requires javelin-behavior + * javelin-dom + * javelin-stratcom + */ + +JX.behavior('diffusion-commit-graph', function(config) { + + var nodes = JX.DOM.scry(document.body, 'div', 'commit-graph'); + var cxt; + + // Pick the color for column 'c'. + function color(c) { + var colors = [ + '#cc0000', + '#cc0099', + '#6600cc', + '#0033cc', + '#00cccc', + '#00cc33', + '#66cc00', + '#cc9900', + ]; + return colors[c % colors.length]; + } + + // Stroke a line (for lines between commits). + function lstroke(c) { + cxt.lineWidth = 3; + cxt.strokeStyle = '#ffffff'; + cxt.stroke(); + cxt.lineWidth = 1; + cxt.strokeStyle = color(c); + cxt.stroke(); + } + + // Stroke with fill (for commit circles). + function fstroke(c) { + cxt.fillStyle = color(c); + cxt.strokeStyle = '#ffffff'; + cxt.fill(); + cxt.stroke(); + } + + + for (var ii = 0; ii < nodes.length; ii++) { + var data = JX.Stratcom.getData(nodes[ii]); + + var cell = 12; // Width of each thread. + function xpos(col) { + return (col * cell) + (cell / 2); + } + + var h = 24; + var w = cell * config.count; + + var canvas = JX.$N('canvas', {width: w, height: h}); + cxt = canvas.getContext('2d'); + + cxt.lineWidth = 3; + // This gives us sharper lines, since lines drawn on an integer (like 5) + // are drawn from 4.5 to 5.5. + cxt.translate(0.5, 0.5); + + cxt.strokeStyle = '#ffffff'; + cxt.fillStyle = '#ffffff'; + + // First, figure out which column this commit appears in. It is marked by + // "o" (if it has a commit after it) or "^" (if no other commit has it as + // a parent). We use this to figure out where to draw the join/split lines. + + var origin = null; + for (var jj = 0; jj < data.line.length; jj++) { + var c = data.line.charAt(jj); + switch (c) { + case 'o': + case '^': + origin = xpos(jj); + break; + } + } + + // Draw all the join lines. These start at some column at the top of the + // canvas and join the commit's column. They indicate branching. + + for (var jj = 0; jj < data.join.length; jj++) { + var join = data.join[jj]; + var x = xpos(join); + cxt.beginPath(); + cxt.moveTo(x, 0); + cxt.bezierCurveTo(x, h/4, origin, h/4, origin, h/2); + lstroke(join); + } + + // Draw all the split lines. These start at the commit and end at some + // column on the bottom of the canvas. They indicate merging. + + for (var jj = 0; jj < data.split.length; jj++) { + var split = data.split[jj]; + var x = xpos(split); + cxt.beginPath(); + cxt.moveTo(origin, h/2); + cxt.bezierCurveTo(origin, 3*h/4, x, 3*h/4, x, h); + lstroke(split); + } + + // Draw the vertical lines (a branch with no activity at this commit) and + // the commit circles. + + for (var jj = 0; jj < data.line.length; jj++) { + var c = data.line.charAt(jj); + switch (c) { + case 'o': + case '^': + origin = xpos(jj); + case '|': + cxt.beginPath(); + cxt.moveTo(xpos(jj), (c == '^' ? h/2 : 0)); + cxt.lineTo(xpos(jj), h); + lstroke(jj); + + if (c == 'o' || c == '^') { + cxt.beginPath(); + cxt.arc(xpos(jj), h/2, 3, 0, 2 * Math.PI, true); + fstroke(jj); + } + break; + } + } + + JX.DOM.setContent(nodes[ii], canvas); + } + + +}); +