diff --git a/resources/celerity/map.php b/resources/celerity/map.php --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -389,6 +389,7 @@ 'rsrc/js/application/diffusion/behavior-pull-lastmodified.js' => 'c715c123', 'rsrc/js/application/doorkeeper/behavior-doorkeeper-tag.js' => '6a85bc5a', 'rsrc/js/application/drydock/drydock-live-operation-status.js' => '47a0728b', + 'rsrc/js/application/fact/Chart.js' => 'fcb0c07d', 'rsrc/js/application/files/behavior-document-engine.js' => '243d6c22', 'rsrc/js/application/files/behavior-icon-composer.js' => '38a6cedb', 'rsrc/js/application/files/behavior-launch-icon-composer.js' => 'a17b84f1', @@ -397,7 +398,7 @@ 'rsrc/js/application/herald/PathTypeahead.js' => 'ad486db3', 'rsrc/js/application/herald/herald-rule-editor.js' => '0922e81d', 'rsrc/js/application/maniphest/behavior-batch-selector.js' => '139ef688', - 'rsrc/js/application/maniphest/behavior-line-chart.js' => '495cf14d', + 'rsrc/js/application/maniphest/behavior-line-chart.js' => 'ad258e28', 'rsrc/js/application/maniphest/behavior-list-edit.js' => 'c687e867', 'rsrc/js/application/owners/OwnersPathEditor.js' => '2a8b62d9', 'rsrc/js/application/owners/owners-path-editor.js' => 'ff688a7a', @@ -625,7 +626,7 @@ 'javelin-behavior-icon-composer' => '38a6cedb', 'javelin-behavior-launch-icon-composer' => 'a17b84f1', 'javelin-behavior-lightbox-attachments' => 'c7e748bf', - 'javelin-behavior-line-chart' => '495cf14d', + 'javelin-behavior-line-chart' => 'ad258e28', 'javelin-behavior-linked-container' => '74446546', 'javelin-behavior-maniphest-batch-selector' => '139ef688', 'javelin-behavior-maniphest-list-editor' => 'c687e867', @@ -695,6 +696,7 @@ 'javelin-behavior-user-menu' => '60cd9241', 'javelin-behavior-view-placeholder' => 'a9942052', 'javelin-behavior-workflow' => '9623adc1', + 'javelin-chart' => 'fcb0c07d', 'javelin-color' => '78f811c9', 'javelin-cookie' => '05d290ef', 'javelin-diffusion-locate-file-source' => '94243d89', @@ -1319,12 +1321,6 @@ '490e2e2e' => array( 'phui-oi-list-view-css', ), - '495cf14d' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-vector', - 'phui-chart-css', - ), '4a7fb02b' => array( 'javelin-behavior', 'javelin-dom', @@ -1861,6 +1857,11 @@ 'javelin-request', 'javelin-router', ), + 'ad258e28' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-chart', + ), 'ad486db3' => array( 'javelin-install', 'javelin-typeahead', @@ -2179,6 +2180,10 @@ 'fa74cc35' => array( 'phui-oi-list-view-css', ), + 'fcb0c07d' => array( + 'phui-chart-css', + 'd3', + ), 'fdc13e4e' => array( 'javelin-install', ), diff --git a/src/applications/fact/application/PhabricatorFactApplication.php b/src/applications/fact/application/PhabricatorFactApplication.php --- a/src/applications/fact/application/PhabricatorFactApplication.php +++ b/src/applications/fact/application/PhabricatorFactApplication.php @@ -30,7 +30,7 @@ return array( '/fact/' => array( '' => 'PhabricatorFactHomeController', - 'chart/' => 'PhabricatorFactChartController', + '(?chart|draw)/' => 'PhabricatorFactChartController', 'object/(?[^/]+)/' => 'PhabricatorFactObjectController', ), ); diff --git a/src/applications/fact/controller/PhabricatorFactChartController.php b/src/applications/fact/controller/PhabricatorFactChartController.php --- a/src/applications/fact/controller/PhabricatorFactChartController.php +++ b/src/applications/fact/controller/PhabricatorFactChartController.php @@ -5,6 +5,13 @@ public function handleRequest(AphrontRequest $request) { $viewer = $request->getViewer(); + // When drawing a chart, we send down a placeholder piece of HTML first, + // then fetch the data via async request. Determine if we're drawing + // the structure or actually pulling the data. + $mode = $request->getURIData('mode'); + $is_chart_mode = ($mode === 'chart'); + $is_draw_mode = ($mode === 'draw'); + $series = $request->getStr('y1'); $facts = PhabricatorFact::getAllFacts(); @@ -20,6 +27,10 @@ return new Aphront404Response(); } + if ($is_chart_mode) { + return $this->newChartResponse(); + } + $table = $fact->newDatapoint(); $conn_r = $table->establishConnection('r'); $table_name = $table->getTableName(); @@ -63,14 +74,13 @@ 'color' => '#ff0000', ); - // Add a dummy "y = x" dataset to prove we can draw multiple datasets. $x_min = min(array_keys($points)); $x_max = max(array_keys($points)); $x_range = ($x_max - $x_min) / 4; $linear = array(); foreach ($points as $x => $y) { - $linear[$x] = count($points) * (($x - $x_min) / $x_range); + $linear[$x] = round(count($points) * (($x - $x_min) / $x_range)); } $datasets[] = array( 'x' => array_keys($linear), @@ -78,19 +88,6 @@ 'color' => '#0000ff', ); - - $id = celerity_generate_unique_node_id(); - $chart = phutil_tag( - 'div', - array( - 'id' => $id, - 'style' => 'background: #ffffff; '. - 'height: 480px; ', - ), - ''); - - require_celerity_resource('d3'); - $y_min = 0; $y_max = 0; $x_min = null; @@ -112,21 +109,43 @@ $x_max = max($x_max, max($dataset['x'])); } + $chart_data = array( + 'datasets' => $datasets, + 'xMin' => $x_min, + 'xMax' => $x_max, + 'yMin' => $y_min, + 'yMax' => $y_max, + ); + + return id(new AphrontAjaxResponse())->setContent($chart_data); + } + + private function newChartResponse() { + $request = $this->getRequest(); + $chart_node_id = celerity_generate_unique_node_id(); + + $chart_view = phutil_tag( + 'div', + array( + 'id' => $chart_node_id, + 'style' => 'background: #ffffff; '. + 'height: 480px; ', + ), + ''); + + $data_uri = $request->getRequestURI(); + $data_uri->setPath('/fact/draw/'); + Javelin::initBehavior( 'line-chart', array( - 'hardpoint' => $id, - 'datasets' => $datasets, - 'xMin' => $x_min, - 'xMax' => $x_max, - 'yMin' => $y_min, - 'yMax' => $y_max, - 'xformat' => 'epoch', + 'chartNodeID' => $chart_node_id, + 'dataURI' => (string)$data_uri, )); $box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Count of %s', $fact->getName())) - ->appendChild($chart); + ->setHeaderText(pht('Chart')) + ->appendChild($chart_view); $crumbs = $this->buildApplicationCrumbs() ->addTextCrumb(pht('Chart')) diff --git a/webroot/rsrc/js/application/fact/Chart.js b/webroot/rsrc/js/application/fact/Chart.js new file mode 100644 --- /dev/null +++ b/webroot/rsrc/js/application/fact/Chart.js @@ -0,0 +1,156 @@ +/** + * @provides javelin-chart + * @requires phui-chart-css + * d3 + */ +JX.install('Chart', { + + construct: function(root_node) { + this._rootNode = root_node; + + JX.Stratcom.listen('resize', null, JX.bind(this, this._redraw)); + }, + + members: { + _rootNode: null, + _data: null, + + setData: function(blob) { + this._data = blob; + this._redraw(); + }, + + _redraw: function() { + if (!this._data) { + return; + } + + var hardpoint = this._rootNode; + var viewport = JX.Vector.getDim(hardpoint); + var config = this._data; + + function css_function(n) { + return n + '(' + JX.$A(arguments).slice(1).join(', ') + ')'; + } + + var padding = { + top: 24, + left: 48, + bottom: 48, + right: 32 + }; + + var size = { + frameWidth: viewport.x, + frameHeight: viewport.y, + }; + + size.width = size.frameWidth - padding.left - padding.right; + size.height = size.frameHeight - padding.top - padding.bottom; + + var x = d3.time.scale() + .range([0, size.width]); + + var y = d3.scale.linear() + .range([size.height, 0]); + + var xAxis = d3.svg.axis() + .scale(x) + .orient('bottom'); + + var yAxis = d3.svg.axis() + .scale(y) + .orient('left'); + + // Remove the old chart (if one exists) before drawing the new chart. + JX.DOM.setContent(hardpoint, []); + + var svg = d3.select('#' + hardpoint.id).append('svg') + .attr('width', size.frameWidth) + .attr('height', size.frameHeight) + .attr('class', 'chart'); + + var g = svg.append('g') + .attr( + 'transform', + css_function('translate', padding.left, padding.top)); + + g.append('rect') + .attr('class', 'inner') + .attr('width', size.width) + .attr('height', size.height); + + function as_date(value) { + return new Date(value * 1000); + } + + x.domain([as_date(config.xMin), as_date(config.xMax)]); + y.domain([config.yMin, config.yMax]); + + var div = d3.select('body') + .append('div') + .attr('class', 'chart-tooltip') + .style('opacity', 0); + + for (var idx = 0; idx < config.datasets.length; idx++) { + var dataset = config.datasets[idx]; + + var line = d3.svg.line() + .x(function(d) { return x(d.xvalue); }) + .y(function(d) { return y(d.yvalue); }); + + var data = []; + for (var ii = 0; ii < dataset.x.length; ii++) { + data.push( + { + xvalue: as_date(dataset.x[ii]), + yvalue: dataset.y[ii] + }); + } + + g.append('path') + .datum(data) + .attr('class', 'line') + .style('stroke', dataset.color) + .attr('d', line); + + g.selectAll('dot') + .data(data) + .enter() + .append('circle') + .attr('class', 'point') + .attr('r', 3) + .attr('cx', function(d) { return x(d.xvalue); }) + .attr('cy', function(d) { return y(d.yvalue); }) + .on('mouseover', function(d) { + var d_y = d.xvalue.getFullYear(); + + // NOTE: Javascript months are zero-based. See PHI1017. + var d_m = d.xvalue.getMonth() + 1; + + var d_d = d.xvalue.getDate(); + + div + .html(d_y + '-' + d_m + '-' + d_d + ': ' + d.yvalue) + .style('opacity', 0.9) + .style('left', (d3.event.pageX - 60) + 'px') + .style('top', (d3.event.pageY - 38) + 'px'); + }) + .on('mouseout', function() { + div.style('opacity', 0); + }); + } + + g.append('g') + .attr('class', 'x axis') + .attr('transform', css_function('translate', 0, size.height)) + .call(xAxis); + + g.append('g') + .attr('class', 'y axis') + .attr('transform', css_function('translate', 0, 0)) + .call(yAxis); + } + } + +}); diff --git a/webroot/rsrc/js/application/maniphest/behavior-line-chart.js b/webroot/rsrc/js/application/maniphest/behavior-line-chart.js --- a/webroot/rsrc/js/application/maniphest/behavior-line-chart.js +++ b/webroot/rsrc/js/application/maniphest/behavior-line-chart.js @@ -2,130 +2,18 @@ * @provides javelin-behavior-line-chart * @requires javelin-behavior * javelin-dom - * javelin-vector - * phui-chart-css + * javelin-chart */ JX.behavior('line-chart', function(config) { + var chart_node = JX.$(config.chartNodeID); - function css_function(n) { - return n + '(' + JX.$A(arguments).slice(1).join(', ') + ')'; - } - - var h = JX.$(config.hardpoint); - var d = JX.Vector.getDim(h); - - var padding = { - top: 24, - left: 48, - bottom: 48, - right: 32 - }; - - var size = { - frameWidth: d.x, - frameHeight: d.y, - }; - - size.width = size.frameWidth - padding.left - padding.right; - size.height = size.frameHeight - padding.top - padding.bottom; - - var x = d3.time.scale() - .range([0, size.width]); - - var y = d3.scale.linear() - .range([size.height, 0]); + var chart = new JX.Chart(chart_node); - var xAxis = d3.svg.axis() - .scale(x) - .orient('bottom'); - - var yAxis = d3.svg.axis() - .scale(y) - .orient('left'); - - var svg = d3.select('#' + config.hardpoint).append('svg') - .attr('width', size.frameWidth) - .attr('height', size.frameHeight) - .attr('class', 'chart'); - - var g = svg.append('g') - .attr('transform', css_function('translate', padding.left, padding.top)); - - g.append('rect') - .attr('class', 'inner') - .attr('width', size.width) - .attr('height', size.height); - - function as_date(value) { - return new Date(value * 1000); + function onresponse(r) { + chart.setData(r); } - x.domain([as_date(config.xMin), as_date(config.xMax)]); - y.domain([config.yMin, config.yMax]); - - for (var idx = 0; idx < config.datasets.length; idx++) { - var dataset = config.datasets[idx]; - - var line = d3.svg.line() - .x(function(d) { return x(d.xvalue); }) - .y(function(d) { return y(d.yvalue); }); - - var data = []; - for (var ii = 0; ii < dataset.x.length; ii++) { - data.push( - { - xvalue: as_date(dataset.x[ii]), - yvalue: dataset.y[ii] - }); - } - - g.append('path') - .datum(data) - .attr('class', 'line') - .style('stroke', dataset.color) - .attr('d', line); - - g.selectAll('dot') - .data(data) - .enter() - .append('circle') - .attr('class', 'point') - .attr('r', 3) - .attr('cx', function(d) { return x(d.xvalue); }) - .attr('cy', function(d) { return y(d.yvalue); }) - .on('mouseover', function(d) { - var d_y = d.xvalue.getFullYear(); - - // NOTE: Javascript months are zero-based. See PHI1017. - var d_m = d.xvalue.getMonth() + 1; - - var d_d = d.xvalue.getDate(); - - div - .html(d_y + '-' + d_m + '-' + d_d + ': ' + d.yvalue) - .style('opacity', 0.9) - .style('left', (d3.event.pageX - 60) + 'px') - .style('top', (d3.event.pageY - 38) + 'px'); - }) - .on('mouseout', function() { - div.style('opacity', 0); - }); - } - - g.append('g') - .attr('class', 'x axis') - .attr('transform', css_function('translate', 0, size.height)) - .call(xAxis); - - g.append('g') - .attr('class', 'y axis') - .attr('transform', css_function('translate', 0, 0)) - .call(yAxis); - - var div = d3.select('body') - .append('div') - .attr('class', 'chart-tooltip') - .style('opacity', 0); - + new JX.Request(config.dataURI, onresponse) + .send(); });