diff --git a/resources/celerity/map.php b/resources/celerity/map.php --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -389,7 +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/fact/Chart.js' => 'a3516cea', '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', @@ -696,7 +696,7 @@ 'javelin-behavior-user-menu' => '60cd9241', 'javelin-behavior-view-placeholder' => 'a9942052', 'javelin-behavior-workflow' => '9623adc1', - 'javelin-chart' => 'fcb0c07d', + 'javelin-chart' => 'a3516cea', 'javelin-color' => '78f811c9', 'javelin-cookie' => '05d290ef', 'javelin-diffusion-locate-file-source' => '94243d89', @@ -1767,6 +1767,10 @@ 'javelin-workflow', 'phabricator-draggable-list', ), + 'a3516cea' => array( + 'phui-chart-css', + 'd3', + ), 'a4356cde' => array( 'javelin-install', 'javelin-dom', @@ -2180,10 +2184,6 @@ 'fa74cc35' => array( 'phui-oi-list-view-css', ), - 'fcb0c07d' => array( - 'phui-chart-css', - 'd3', - ), 'fdc13e4e' => array( 'javelin-install', ), diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -2669,6 +2669,7 @@ 'PhabricatorChartFunctionArgument' => 'applications/fact/chart/PhabricatorChartFunctionArgument.php', 'PhabricatorChartFunctionArgumentParser' => 'applications/fact/chart/PhabricatorChartFunctionArgumentParser.php', 'PhabricatorChartRenderingEngine' => 'applications/fact/engine/PhabricatorChartRenderingEngine.php', + 'PhabricatorChartStackedAreaDataset' => 'applications/fact/chart/PhabricatorChartStackedAreaDataset.php', 'PhabricatorChatLogApplication' => 'applications/chatlog/application/PhabricatorChatLogApplication.php', 'PhabricatorChatLogChannel' => 'applications/chatlog/storage/PhabricatorChatLogChannel.php', 'PhabricatorChatLogChannelListController' => 'applications/chatlog/controller/PhabricatorChatLogChannelListController.php', @@ -8683,6 +8684,7 @@ 'PhabricatorChartFunctionArgument' => 'Phobject', 'PhabricatorChartFunctionArgumentParser' => 'Phobject', 'PhabricatorChartRenderingEngine' => 'Phobject', + 'PhabricatorChartStackedAreaDataset' => 'PhabricatorChartDataset', 'PhabricatorChatLogApplication' => 'PhabricatorApplication', 'PhabricatorChatLogChannel' => array( 'PhabricatorChatLogDAO', diff --git a/src/applications/fact/chart/PhabricatorAccumulateChartFunction.php b/src/applications/fact/chart/PhabricatorAccumulateChartFunction.php --- a/src/applications/fact/chart/PhabricatorAccumulateChartFunction.php +++ b/src/applications/fact/chart/PhabricatorAccumulateChartFunction.php @@ -35,8 +35,9 @@ $datasource_xv = $datasource->newInputValues($empty_query); if (!$datasource_xv) { - // TODO: Maybe this should just be an error? - $datasource_xv = $xv; + // When the datasource has no datapoints, we can't evaluate the function + // anywhere. + return array_fill(0, count($xv), null); } $yv = $datasource->evaluateFunction($datasource_xv); diff --git a/src/applications/fact/chart/PhabricatorChartDataset.php b/src/applications/fact/chart/PhabricatorChartDataset.php --- a/src/applications/fact/chart/PhabricatorChartDataset.php +++ b/src/applications/fact/chart/PhabricatorChartDataset.php @@ -1,42 +1,77 @@ function; + final public function getDatasetTypeKey() { + return $this->getPhobjectClassConstant('DATASETKEY', 32); } - public function setFunction(PhabricatorComposeChartFunction $function) { - $this->function = $function; + final public function getFunctions() { + return $this->functions; + } + + final public function setFunctions(array $functions) { + assert_instances_of($functions, 'PhabricatorComposeChartFunction'); + + $this->functions = $functions; + return $this; } - public static function newFromDictionary(array $map) { + final public static function getAllDatasetTypes() { + return id(new PhutilClassMapQuery()) + ->setAncestorClass(__CLASS__) + ->setUniqueMethod('getDatasetTypeKey') + ->execute(); + } + + final public static function newFromDictionary(array $map) { PhutilTypeSpec::checkMap( $map, array( - 'function' => 'list', + 'type' => 'string', + 'functions' => 'list', )); - $dataset = new self(); + $types = self::getAllDatasetTypes(); + + $dataset_type = $map['type']; + if (!isset($types[$dataset_type])) { + throw new Exception( + pht( + 'Trying to construct a dataset of type "%s", but this type is '. + 'unknown. Supported types are: %s.', + $dataset_type, + implode(', ', array_keys($types)))); + } - $dataset->function = id(new PhabricatorComposeChartFunction()) - ->setArguments(array($map['function'])); + $dataset = id(clone $types[$dataset_type]); + + $functions = array(); + foreach ($map['functions'] as $map) { + $functions[] = PhabricatorChartFunction::newFromDictionary($map); + } + $dataset->setFunctions($functions); return $dataset; } - public function toDictionary() { - // Since we wrap the raw value in a "compose(...)", when deserializing, - // we need to unwrap it when serializing. - $function_raw = head($this->getFunction()->toDictionary()); - + final public function toDictionary() { return array( - 'function' => $function_raw, + 'type' => $this->getDatasetTypeKey(), + 'functions' => mpull($this->getFunctions(), 'toDictionary'), ); } + final public function getWireFormat(PhabricatorChartDataQuery $data_query) { + return $this->newWireFormat($data_query); + } + + abstract protected function newWireFormat( + PhabricatorChartDataQuery $data_query); + + } diff --git a/src/applications/fact/chart/PhabricatorChartFunction.php b/src/applications/fact/chart/PhabricatorChartFunction.php --- a/src/applications/fact/chart/PhabricatorChartFunction.php +++ b/src/applications/fact/chart/PhabricatorChartFunction.php @@ -43,8 +43,37 @@ return $this; } + final public static function newFromDictionary(array $map) { + PhutilTypeSpec::checkMap( + $map, + array( + 'function' => 'string', + 'arguments' => 'list', + )); + + $functions = self::getAllFunctions(); + + $function_name = $map['function']; + if (!isset($functions[$function_name])) { + throw new Exception( + pht( + 'Attempting to build function "%s" from dictionary, but that '. + 'function is unknown. Known functions are: %s.', + $function_name, + implode(', ', array_keys($functions)))); + } + + $function = id(clone $functions[$function_name]) + ->setArguments($map['arguments']); + + return $function; + } + public function toDictionary() { - return $this->getArgumentParser()->getRawArguments(); + return array( + 'function' => $this->getFunctionKey(), + 'arguments' => $this->getArgumentParser()->getRawArguments(), + ); } public function getSubfunctions() { diff --git a/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php b/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php new file mode 100644 --- /dev/null +++ b/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php @@ -0,0 +1,149 @@ +getFunctions(); + + $function_points = array(); + foreach ($functions as $function_idx => $function) { + $function_points[$function_idx] = array(); + + $datapoints = $function->newDatapoints($data_query); + foreach ($datapoints as $point) { + $x = $point['x']; + $function_points[$function_idx][$x] = $point; + } + } + + $raw_points = $function_points; + + // We need to define every function we're drawing at every point where + // any of the functions we're drawing are defined. If we don't, we'll + // end up with weird gaps or overlaps between adjacent areas, and won't + // know how much we need to lift each point above the baseline when + // stacking the functions on top of one another. + + $must_define = array(); + foreach ($function_points as $function_idx => $points) { + foreach ($points as $x => $point) { + $must_define[$x] = $x; + } + } + ksort($must_define); + + foreach ($functions as $function_idx => $function) { + $missing = array(); + foreach ($must_define as $x) { + if (!isset($function_points[$function_idx][$x])) { + $missing[$x] = true; + } + } + + if (!$missing) { + continue; + } + + $points = $function_points[$function_idx]; + + $values = array_keys($points); + $cursor = -1; + $length = count($values); + + foreach ($missing as $x => $ignored) { + // Move the cursor forward until we find the last point before "x" + // which is defined. + while ($cursor + 1 < $length && $values[$cursor + 1] < $x) { + $cursor++; + } + + // If this new point is to the left of all defined points, we'll + // assume the value is 0. If the point is to the right of all defined + // points, we assume the value is the same as the last known value. + + // If it's between two defined points, we average them. + + if ($cursor < 0) { + $y = 0; + } else if ($cursor + 1 < $length) { + $xmin = $values[$cursor]; + $xmax = $values[$cursor + 1]; + + $ymin = $points[$xmin]['y']; + $ymax = $points[$xmax]['y']; + + // Fill in the missing point by creating a linear interpolation + // between the two adjacent points. + $distance = ($x - $xmin) / ($xmax - $xmin); + $y = $ymin + (($ymax - $ymin) * $distance); + } else { + $xmin = $values[$cursor]; + $y = $function_points[$function_idx][$xmin]['y']; + } + + $function_points[$function_idx][$x] = array( + 'x' => $x, + 'y' => $y, + ); + } + + ksort($function_points[$function_idx]); + } + + $series = array(); + $baseline = array(); + foreach ($function_points as $function_idx => $points) { + $below = idx($function_points, $function_idx - 1); + + $bounds = array(); + foreach ($points as $x => $point) { + if (!isset($baseline[$x])) { + $baseline[$x] = 0; + } + + $y0 = $baseline[$x]; + $baseline[$x] += $point['y']; + $y1 = $baseline[$x]; + + $bounds[] = array( + 'x' => $x, + 'y0' => $y0, + 'y1' => $y1, + ); + + if (isset($raw_points[$function_idx][$x])) { + $raw_points[$function_idx][$x]['y1'] = $y1; + } + } + + $series[] = $bounds; + } + + $events = array(); + foreach ($raw_points as $function_idx => $points) { + $event_list = array(); + foreach ($points as $point) { + $event_list[] = $point; + } + $events[] = $event_list; + } + + $result = array( + 'type' => $this->getDatasetTypeKey(), + 'data' => $series, + 'events' => $events, + 'color' => array( + 'blue', + 'cyan', + 'green', + ), + ); + + return $result; + } + + +} diff --git a/src/applications/fact/engine/PhabricatorChartRenderingEngine.php b/src/applications/fact/engine/PhabricatorChartRenderingEngine.php --- a/src/applications/fact/engine/PhabricatorChartRenderingEngine.php +++ b/src/applications/fact/engine/PhabricatorChartRenderingEngine.php @@ -119,7 +119,9 @@ $functions = array(); foreach ($datasets as $dataset) { - $functions[] = $dataset->getFunction(); + foreach ($dataset->getFunctions() as $function) { + $functions[] = $function; + } } $subfunctions = array(); @@ -144,39 +146,17 @@ ->setMaximumValue($domain_max) ->setLimit(2000); - $datasets = array(); - foreach ($functions as $function) { - $points = $function->newDatapoints($data_query); - - $x = array(); - $y = array(); - - foreach ($points as $point) { - $x[] = $point['x']; - $y[] = $point['y']; - } - - $datasets[] = array( - 'x' => $x, - 'y' => $y, - 'color' => '#ff00ff', - ); - } - - - $y_min = 0; - $y_max = 0; + $wire_datasets = array(); foreach ($datasets as $dataset) { - if (!$dataset['y']) { - continue; - } - - $y_min = min($y_min, min($dataset['y'])); - $y_max = max($y_max, max($dataset['y'])); + $wire_datasets[] = $dataset->getWireFormat($data_query); } + // TODO: Figure these out from the datasets again. + $y_min = -2; + $y_max = 20; + $chart_data = array( - 'datasets' => $datasets, + 'datasets' => $wire_datasets, 'xMin' => $domain_min, 'xMax' => $domain_max, 'yMin' => $y_min, diff --git a/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php b/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php --- a/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php +++ b/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php @@ -30,38 +30,34 @@ if ($project_phids) { foreach ($project_phids as $project_phid) { $argvs[] = array( - 'sum', - array( - 'accumulate', - array('fact', 'tasks.open-count.create.project', $project_phid), - ), - array( - 'accumulate', - array('fact', 'tasks.open-count.status.project', $project_phid), - ), - array( - 'accumulate', - array('fact', 'tasks.open-count.assign.project', $project_phid), - ), + 'accumulate', + array('fact', 'tasks.open-count.create.project', $project_phid), + ); + $argvs[] = array( + 'accumulate', + array('fact', 'tasks.open-count.status.project', $project_phid), + ); + $argvs[] = array( + 'accumulate', + array('fact', 'tasks.open-count.assign.project', $project_phid), ); } } else { - $argvs[] = array( - 'sum', - array('accumulate', array('fact', 'tasks.open-count.create')), - array('accumulate', array('fact', 'tasks.open-count.status')), - ); + $argvs[] = array('accumulate', array('fact', 'tasks.open-count.create')); + $argvs[] = array('accumulate', array('fact', 'tasks.open-count.status')); } - $datasets = array(); + $functions = array(); foreach ($argvs as $argv) { - $function = id(new PhabricatorComposeChartFunction()) + $functions[] = id(new PhabricatorComposeChartFunction()) ->setArguments(array($argv)); - - $datasets[] = id(new PhabricatorChartDataset()) - ->setFunction($function); } + $datasets = array(); + + $datasets[] = id(new PhabricatorChartStackedAreaDataset()) + ->setFunctions($functions); + $chart = id(new PhabricatorFactChart()) ->setDatasets($datasets); diff --git a/webroot/rsrc/js/application/fact/Chart.js b/webroot/rsrc/js/application/fact/Chart.js --- a/webroot/rsrc/js/application/fact/Chart.js +++ b/webroot/rsrc/js/application/fact/Chart.js @@ -26,6 +26,10 @@ } var hardpoint = this._rootNode; + + // Remove the old chart (if one exists) before drawing the new chart. + JX.DOM.setContent(hardpoint, []); + var viewport = JX.Vector.getDim(hardpoint); var config = this._data; @@ -48,22 +52,14 @@ size.width = size.frameWidth - padding.left - padding.right; size.height = size.frameHeight - padding.top - padding.bottom; - var x = d3.time.scale() + var x = d3.scaleTime() .range([0, size.width]); - var y = d3.scale.linear() + var y = d3.scaleLinear() .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 xAxis = d3.axisBottom(x); + var yAxis = d3.axisLeft(y); var svg = d3.select('#' + hardpoint.id).append('svg') .attr('width', size.frameWidth) @@ -80,11 +76,7 @@ .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)]); + x.domain([this._newDate(config.xMin), this._newDate(config.xMax)]); y.domain([config.yMin, config.yMax]); var div = d3.select('body') @@ -95,43 +87,66 @@ 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] - }); + switch (dataset.type) { + case 'stacked-area': + this._newStackedArea(g, dataset, x, y, div); + break; } + } + + 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); + }, + + _newStackedArea: function(g, dataset, x, y, div) { + var to_date = JX.bind(this, this._newDate); + + var area = d3.area() + .x(function(d) { return x(to_date(d.x)); }) + .y0(function(d) { return y(d.y0); }) + .y1(function(d) { return y(d.y1); }); + + var line = d3.line() + .x(function(d) { return x(to_date(d.x)); }) + .y(function(d) { return y(d.y1); }); + + for (var ii = 0; ii < dataset.data.length; ii++) { + g.append('path') + .style('fill', dataset.color[ii % dataset.color.length]) + .style('opacity', '0.15') + .attr('d', area(dataset.data[ii])); g.append('path') - .datum(data) .attr('class', 'line') - .style('stroke', dataset.color) - .attr('d', line); + .attr('d', line(dataset.data[ii])); g.selectAll('dot') - .data(data) + .data(dataset.events[ii]) .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); }) + .attr('cx', function(d) { return x(to_date(d.x)); }) + .attr('cy', function(d) { return y(d.y1); }) .on('mouseover', function(d) { - var d_y = d.xvalue.getFullYear(); + var dd = to_date(d.x); + + var d_y = dd.getFullYear(); // NOTE: Javascript months are zero-based. See PHI1017. - var d_m = d.xvalue.getMonth() + 1; + var d_m = dd.getMonth() + 1; - var d_d = d.xvalue.getDate(); + var d_d = dd.getDate(); div - .html(d_y + '-' + d_m + '-' + d_d + ': ' + d.yvalue) + .html(d_y + '-' + d_m + '-' + d_d + ': ' + d.y1) .style('opacity', 0.9) .style('left', (d3.event.pageX - 60) + 'px') .style('top', (d3.event.pageY - 38) + 'px'); @@ -139,18 +154,14 @@ .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); + _newDate: function(epoch) { + return new Date(epoch * 1000); } + } });