@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Roughly support stacked area charts

Summary:
Ref T13279. This adds support for:

- Datasets can have types, like "stacked area".
- Datasets can have multiple functions.
- Charts can store dataset types and datasets with multiple functions.
- Adds a "stacked area" dataset.
- Makes D3 actually draw a stacked area chart.

Lots of rough edges here still, but the result looks slightly more like it's supposed to look.

D3 can do some of this logic itself, like adding up the area stacks on top of one another with `d3.stack()`. I'm doing it in PHP instead because I think it's a bit easier to debug, and it gives us more options for things like caching or "export to CSV" or "export to API" or rendering a data table under the chart or whatever.

Test Plan: {F6427780}

Reviewers: amckinley

Reviewed By: amckinley

Subscribers: yelirekim

Maniphest Tasks: T13279

Differential Revision: https://secure.phabricator.com/D20498

+329 -126
+6 -6
resources/celerity/map.php
··· 389 389 'rsrc/js/application/diffusion/behavior-pull-lastmodified.js' => 'c715c123', 390 390 'rsrc/js/application/doorkeeper/behavior-doorkeeper-tag.js' => '6a85bc5a', 391 391 'rsrc/js/application/drydock/drydock-live-operation-status.js' => '47a0728b', 392 - 'rsrc/js/application/fact/Chart.js' => 'fcb0c07d', 392 + 'rsrc/js/application/fact/Chart.js' => 'a3516cea', 393 393 'rsrc/js/application/files/behavior-document-engine.js' => '243d6c22', 394 394 'rsrc/js/application/files/behavior-icon-composer.js' => '38a6cedb', 395 395 'rsrc/js/application/files/behavior-launch-icon-composer.js' => 'a17b84f1', ··· 696 696 'javelin-behavior-user-menu' => '60cd9241', 697 697 'javelin-behavior-view-placeholder' => 'a9942052', 698 698 'javelin-behavior-workflow' => '9623adc1', 699 - 'javelin-chart' => 'fcb0c07d', 699 + 'javelin-chart' => 'a3516cea', 700 700 'javelin-color' => '78f811c9', 701 701 'javelin-cookie' => '05d290ef', 702 702 'javelin-diffusion-locate-file-source' => '94243d89', ··· 1767 1767 'javelin-workflow', 1768 1768 'phabricator-draggable-list', 1769 1769 ), 1770 + 'a3516cea' => array( 1771 + 'phui-chart-css', 1772 + 'd3', 1773 + ), 1770 1774 'a4356cde' => array( 1771 1775 'javelin-install', 1772 1776 'javelin-dom', ··· 2179 2183 ), 2180 2184 'fa74cc35' => array( 2181 2185 'phui-oi-list-view-css', 2182 - ), 2183 - 'fcb0c07d' => array( 2184 - 'phui-chart-css', 2185 - 'd3', 2186 2186 ), 2187 2187 'fdc13e4e' => array( 2188 2188 'javelin-install',
+2
src/__phutil_library_map__.php
··· 2669 2669 'PhabricatorChartFunctionArgument' => 'applications/fact/chart/PhabricatorChartFunctionArgument.php', 2670 2670 'PhabricatorChartFunctionArgumentParser' => 'applications/fact/chart/PhabricatorChartFunctionArgumentParser.php', 2671 2671 'PhabricatorChartRenderingEngine' => 'applications/fact/engine/PhabricatorChartRenderingEngine.php', 2672 + 'PhabricatorChartStackedAreaDataset' => 'applications/fact/chart/PhabricatorChartStackedAreaDataset.php', 2672 2673 'PhabricatorChatLogApplication' => 'applications/chatlog/application/PhabricatorChatLogApplication.php', 2673 2674 'PhabricatorChatLogChannel' => 'applications/chatlog/storage/PhabricatorChatLogChannel.php', 2674 2675 'PhabricatorChatLogChannelListController' => 'applications/chatlog/controller/PhabricatorChatLogChannelListController.php', ··· 8683 8684 'PhabricatorChartFunctionArgument' => 'Phobject', 8684 8685 'PhabricatorChartFunctionArgumentParser' => 'Phobject', 8685 8686 'PhabricatorChartRenderingEngine' => 'Phobject', 8687 + 'PhabricatorChartStackedAreaDataset' => 'PhabricatorChartDataset', 8686 8688 'PhabricatorChatLogApplication' => 'PhabricatorApplication', 8687 8689 'PhabricatorChatLogChannel' => array( 8688 8690 'PhabricatorChatLogDAO',
+3 -2
src/applications/fact/chart/PhabricatorAccumulateChartFunction.php
··· 35 35 36 36 $datasource_xv = $datasource->newInputValues($empty_query); 37 37 if (!$datasource_xv) { 38 - // TODO: Maybe this should just be an error? 39 - $datasource_xv = $xv; 38 + // When the datasource has no datapoints, we can't evaluate the function 39 + // anywhere. 40 + return array_fill(0, count($xv), null); 40 41 } 41 42 42 43 $yv = $datasource->evaluateFunction($datasource_xv);
+52 -17
src/applications/fact/chart/PhabricatorChartDataset.php
··· 1 1 <?php 2 2 3 - final class PhabricatorChartDataset 3 + abstract class PhabricatorChartDataset 4 4 extends Phobject { 5 5 6 - private $function; 6 + private $functions; 7 7 8 - public function getFunction() { 9 - return $this->function; 8 + final public function getDatasetTypeKey() { 9 + return $this->getPhobjectClassConstant('DATASETKEY', 32); 10 10 } 11 11 12 - public function setFunction(PhabricatorComposeChartFunction $function) { 13 - $this->function = $function; 12 + final public function getFunctions() { 13 + return $this->functions; 14 + } 15 + 16 + final public function setFunctions(array $functions) { 17 + assert_instances_of($functions, 'PhabricatorComposeChartFunction'); 18 + 19 + $this->functions = $functions; 20 + 14 21 return $this; 15 22 } 16 23 17 - public static function newFromDictionary(array $map) { 24 + final public static function getAllDatasetTypes() { 25 + return id(new PhutilClassMapQuery()) 26 + ->setAncestorClass(__CLASS__) 27 + ->setUniqueMethod('getDatasetTypeKey') 28 + ->execute(); 29 + } 30 + 31 + final public static function newFromDictionary(array $map) { 18 32 PhutilTypeSpec::checkMap( 19 33 $map, 20 34 array( 21 - 'function' => 'list<wild>', 35 + 'type' => 'string', 36 + 'functions' => 'list<wild>', 22 37 )); 23 38 24 - $dataset = new self(); 39 + $types = self::getAllDatasetTypes(); 40 + 41 + $dataset_type = $map['type']; 42 + if (!isset($types[$dataset_type])) { 43 + throw new Exception( 44 + pht( 45 + 'Trying to construct a dataset of type "%s", but this type is '. 46 + 'unknown. Supported types are: %s.', 47 + $dataset_type, 48 + implode(', ', array_keys($types)))); 49 + } 50 + 51 + $dataset = id(clone $types[$dataset_type]); 25 52 26 - $dataset->function = id(new PhabricatorComposeChartFunction()) 27 - ->setArguments(array($map['function'])); 53 + $functions = array(); 54 + foreach ($map['functions'] as $map) { 55 + $functions[] = PhabricatorChartFunction::newFromDictionary($map); 56 + } 57 + $dataset->setFunctions($functions); 28 58 29 59 return $dataset; 30 60 } 31 61 32 - public function toDictionary() { 33 - // Since we wrap the raw value in a "compose(...)", when deserializing, 34 - // we need to unwrap it when serializing. 35 - $function_raw = head($this->getFunction()->toDictionary()); 36 - 62 + final public function toDictionary() { 37 63 return array( 38 - 'function' => $function_raw, 64 + 'type' => $this->getDatasetTypeKey(), 65 + 'functions' => mpull($this->getFunctions(), 'toDictionary'), 39 66 ); 40 67 } 68 + 69 + final public function getWireFormat(PhabricatorChartDataQuery $data_query) { 70 + return $this->newWireFormat($data_query); 71 + } 72 + 73 + abstract protected function newWireFormat( 74 + PhabricatorChartDataQuery $data_query); 75 + 41 76 42 77 }
+30 -1
src/applications/fact/chart/PhabricatorChartFunction.php
··· 43 43 return $this; 44 44 } 45 45 46 + final public static function newFromDictionary(array $map) { 47 + PhutilTypeSpec::checkMap( 48 + $map, 49 + array( 50 + 'function' => 'string', 51 + 'arguments' => 'list<wild>', 52 + )); 53 + 54 + $functions = self::getAllFunctions(); 55 + 56 + $function_name = $map['function']; 57 + if (!isset($functions[$function_name])) { 58 + throw new Exception( 59 + pht( 60 + 'Attempting to build function "%s" from dictionary, but that '. 61 + 'function is unknown. Known functions are: %s.', 62 + $function_name, 63 + implode(', ', array_keys($functions)))); 64 + } 65 + 66 + $function = id(clone $functions[$function_name]) 67 + ->setArguments($map['arguments']); 68 + 69 + return $function; 70 + } 71 + 46 72 public function toDictionary() { 47 - return $this->getArgumentParser()->getRawArguments(); 73 + return array( 74 + 'function' => $this->getFunctionKey(), 75 + 'arguments' => $this->getArgumentParser()->getRawArguments(), 76 + ); 48 77 } 49 78 50 79 public function getSubfunctions() {
+149
src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php
··· 1 + <?php 2 + 3 + final class PhabricatorChartStackedAreaDataset 4 + extends PhabricatorChartDataset { 5 + 6 + const DATASETKEY = 'stacked-area'; 7 + 8 + protected function newWireFormat(PhabricatorChartDataQuery $data_query) { 9 + $functions = $this->getFunctions(); 10 + 11 + $function_points = array(); 12 + foreach ($functions as $function_idx => $function) { 13 + $function_points[$function_idx] = array(); 14 + 15 + $datapoints = $function->newDatapoints($data_query); 16 + foreach ($datapoints as $point) { 17 + $x = $point['x']; 18 + $function_points[$function_idx][$x] = $point; 19 + } 20 + } 21 + 22 + $raw_points = $function_points; 23 + 24 + // We need to define every function we're drawing at every point where 25 + // any of the functions we're drawing are defined. If we don't, we'll 26 + // end up with weird gaps or overlaps between adjacent areas, and won't 27 + // know how much we need to lift each point above the baseline when 28 + // stacking the functions on top of one another. 29 + 30 + $must_define = array(); 31 + foreach ($function_points as $function_idx => $points) { 32 + foreach ($points as $x => $point) { 33 + $must_define[$x] = $x; 34 + } 35 + } 36 + ksort($must_define); 37 + 38 + foreach ($functions as $function_idx => $function) { 39 + $missing = array(); 40 + foreach ($must_define as $x) { 41 + if (!isset($function_points[$function_idx][$x])) { 42 + $missing[$x] = true; 43 + } 44 + } 45 + 46 + if (!$missing) { 47 + continue; 48 + } 49 + 50 + $points = $function_points[$function_idx]; 51 + 52 + $values = array_keys($points); 53 + $cursor = -1; 54 + $length = count($values); 55 + 56 + foreach ($missing as $x => $ignored) { 57 + // Move the cursor forward until we find the last point before "x" 58 + // which is defined. 59 + while ($cursor + 1 < $length && $values[$cursor + 1] < $x) { 60 + $cursor++; 61 + } 62 + 63 + // If this new point is to the left of all defined points, we'll 64 + // assume the value is 0. If the point is to the right of all defined 65 + // points, we assume the value is the same as the last known value. 66 + 67 + // If it's between two defined points, we average them. 68 + 69 + if ($cursor < 0) { 70 + $y = 0; 71 + } else if ($cursor + 1 < $length) { 72 + $xmin = $values[$cursor]; 73 + $xmax = $values[$cursor + 1]; 74 + 75 + $ymin = $points[$xmin]['y']; 76 + $ymax = $points[$xmax]['y']; 77 + 78 + // Fill in the missing point by creating a linear interpolation 79 + // between the two adjacent points. 80 + $distance = ($x - $xmin) / ($xmax - $xmin); 81 + $y = $ymin + (($ymax - $ymin) * $distance); 82 + } else { 83 + $xmin = $values[$cursor]; 84 + $y = $function_points[$function_idx][$xmin]['y']; 85 + } 86 + 87 + $function_points[$function_idx][$x] = array( 88 + 'x' => $x, 89 + 'y' => $y, 90 + ); 91 + } 92 + 93 + ksort($function_points[$function_idx]); 94 + } 95 + 96 + $series = array(); 97 + $baseline = array(); 98 + foreach ($function_points as $function_idx => $points) { 99 + $below = idx($function_points, $function_idx - 1); 100 + 101 + $bounds = array(); 102 + foreach ($points as $x => $point) { 103 + if (!isset($baseline[$x])) { 104 + $baseline[$x] = 0; 105 + } 106 + 107 + $y0 = $baseline[$x]; 108 + $baseline[$x] += $point['y']; 109 + $y1 = $baseline[$x]; 110 + 111 + $bounds[] = array( 112 + 'x' => $x, 113 + 'y0' => $y0, 114 + 'y1' => $y1, 115 + ); 116 + 117 + if (isset($raw_points[$function_idx][$x])) { 118 + $raw_points[$function_idx][$x]['y1'] = $y1; 119 + } 120 + } 121 + 122 + $series[] = $bounds; 123 + } 124 + 125 + $events = array(); 126 + foreach ($raw_points as $function_idx => $points) { 127 + $event_list = array(); 128 + foreach ($points as $point) { 129 + $event_list[] = $point; 130 + } 131 + $events[] = $event_list; 132 + } 133 + 134 + $result = array( 135 + 'type' => $this->getDatasetTypeKey(), 136 + 'data' => $series, 137 + 'events' => $events, 138 + 'color' => array( 139 + 'blue', 140 + 'cyan', 141 + 'green', 142 + ), 143 + ); 144 + 145 + return $result; 146 + } 147 + 148 + 149 + }
+10 -30
src/applications/fact/engine/PhabricatorChartRenderingEngine.php
··· 119 119 120 120 $functions = array(); 121 121 foreach ($datasets as $dataset) { 122 - $functions[] = $dataset->getFunction(); 122 + foreach ($dataset->getFunctions() as $function) { 123 + $functions[] = $function; 124 + } 123 125 } 124 126 125 127 $subfunctions = array(); ··· 144 146 ->setMaximumValue($domain_max) 145 147 ->setLimit(2000); 146 148 147 - $datasets = array(); 148 - foreach ($functions as $function) { 149 - $points = $function->newDatapoints($data_query); 150 - 151 - $x = array(); 152 - $y = array(); 153 - 154 - foreach ($points as $point) { 155 - $x[] = $point['x']; 156 - $y[] = $point['y']; 157 - } 158 - 159 - $datasets[] = array( 160 - 'x' => $x, 161 - 'y' => $y, 162 - 'color' => '#ff00ff', 163 - ); 164 - } 165 - 166 - 167 - $y_min = 0; 168 - $y_max = 0; 149 + $wire_datasets = array(); 169 150 foreach ($datasets as $dataset) { 170 - if (!$dataset['y']) { 171 - continue; 172 - } 173 - 174 - $y_min = min($y_min, min($dataset['y'])); 175 - $y_max = max($y_max, max($dataset['y'])); 151 + $wire_datasets[] = $dataset->getWireFormat($data_query); 176 152 } 177 153 154 + // TODO: Figure these out from the datasets again. 155 + $y_min = -2; 156 + $y_max = 20; 157 + 178 158 $chart_data = array( 179 - 'datasets' => $datasets, 159 + 'datasets' => $wire_datasets, 180 160 'xMin' => $domain_min, 181 161 'xMax' => $domain_max, 182 162 'yMin' => $y_min,
+19 -23
src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php
··· 30 30 if ($project_phids) { 31 31 foreach ($project_phids as $project_phid) { 32 32 $argvs[] = array( 33 - 'sum', 34 - array( 35 - 'accumulate', 36 - array('fact', 'tasks.open-count.create.project', $project_phid), 37 - ), 38 - array( 39 - 'accumulate', 40 - array('fact', 'tasks.open-count.status.project', $project_phid), 41 - ), 42 - array( 43 - 'accumulate', 44 - array('fact', 'tasks.open-count.assign.project', $project_phid), 45 - ), 33 + 'accumulate', 34 + array('fact', 'tasks.open-count.create.project', $project_phid), 35 + ); 36 + $argvs[] = array( 37 + 'accumulate', 38 + array('fact', 'tasks.open-count.status.project', $project_phid), 39 + ); 40 + $argvs[] = array( 41 + 'accumulate', 42 + array('fact', 'tasks.open-count.assign.project', $project_phid), 46 43 ); 47 44 } 48 45 } else { 49 - $argvs[] = array( 50 - 'sum', 51 - array('accumulate', array('fact', 'tasks.open-count.create')), 52 - array('accumulate', array('fact', 'tasks.open-count.status')), 53 - ); 46 + $argvs[] = array('accumulate', array('fact', 'tasks.open-count.create')); 47 + $argvs[] = array('accumulate', array('fact', 'tasks.open-count.status')); 54 48 } 55 49 56 - $datasets = array(); 50 + $functions = array(); 57 51 foreach ($argvs as $argv) { 58 - $function = id(new PhabricatorComposeChartFunction()) 52 + $functions[] = id(new PhabricatorComposeChartFunction()) 59 53 ->setArguments(array($argv)); 60 - 61 - $datasets[] = id(new PhabricatorChartDataset()) 62 - ->setFunction($function); 63 54 } 55 + 56 + $datasets = array(); 57 + 58 + $datasets[] = id(new PhabricatorChartStackedAreaDataset()) 59 + ->setFunctions($functions); 64 60 65 61 $chart = id(new PhabricatorFactChart()) 66 62 ->setDatasets($datasets);
+58 -47
webroot/rsrc/js/application/fact/Chart.js
··· 26 26 } 27 27 28 28 var hardpoint = this._rootNode; 29 + 30 + // Remove the old chart (if one exists) before drawing the new chart. 31 + JX.DOM.setContent(hardpoint, []); 32 + 29 33 var viewport = JX.Vector.getDim(hardpoint); 30 34 var config = this._data; 31 35 ··· 48 52 size.width = size.frameWidth - padding.left - padding.right; 49 53 size.height = size.frameHeight - padding.top - padding.bottom; 50 54 51 - var x = d3.time.scale() 55 + var x = d3.scaleTime() 52 56 .range([0, size.width]); 53 57 54 - var y = d3.scale.linear() 58 + var y = d3.scaleLinear() 55 59 .range([size.height, 0]); 56 60 57 - var xAxis = d3.svg.axis() 58 - .scale(x) 59 - .orient('bottom'); 60 - 61 - var yAxis = d3.svg.axis() 62 - .scale(y) 63 - .orient('left'); 64 - 65 - // Remove the old chart (if one exists) before drawing the new chart. 66 - JX.DOM.setContent(hardpoint, []); 61 + var xAxis = d3.axisBottom(x); 62 + var yAxis = d3.axisLeft(y); 67 63 68 64 var svg = d3.select('#' + hardpoint.id).append('svg') 69 65 .attr('width', size.frameWidth) ··· 80 76 .attr('width', size.width) 81 77 .attr('height', size.height); 82 78 83 - function as_date(value) { 84 - return new Date(value * 1000); 85 - } 86 - 87 - x.domain([as_date(config.xMin), as_date(config.xMax)]); 79 + x.domain([this._newDate(config.xMin), this._newDate(config.xMax)]); 88 80 y.domain([config.yMin, config.yMax]); 89 81 90 82 var div = d3.select('body') ··· 95 87 for (var idx = 0; idx < config.datasets.length; idx++) { 96 88 var dataset = config.datasets[idx]; 97 89 98 - var line = d3.svg.line() 99 - .x(function(d) { return x(d.xvalue); }) 100 - .y(function(d) { return y(d.yvalue); }); 90 + switch (dataset.type) { 91 + case 'stacked-area': 92 + this._newStackedArea(g, dataset, x, y, div); 93 + break; 94 + } 95 + } 96 + 97 + g.append('g') 98 + .attr('class', 'x axis') 99 + .attr('transform', css_function('translate', 0, size.height)) 100 + .call(xAxis); 101 + 102 + g.append('g') 103 + .attr('class', 'y axis') 104 + .attr('transform', css_function('translate', 0, 0)) 105 + .call(yAxis); 106 + }, 107 + 108 + _newStackedArea: function(g, dataset, x, y, div) { 109 + var to_date = JX.bind(this, this._newDate); 110 + 111 + var area = d3.area() 112 + .x(function(d) { return x(to_date(d.x)); }) 113 + .y0(function(d) { return y(d.y0); }) 114 + .y1(function(d) { return y(d.y1); }); 115 + 116 + var line = d3.line() 117 + .x(function(d) { return x(to_date(d.x)); }) 118 + .y(function(d) { return y(d.y1); }); 101 119 102 - var data = []; 103 - for (var ii = 0; ii < dataset.x.length; ii++) { 104 - data.push( 105 - { 106 - xvalue: as_date(dataset.x[ii]), 107 - yvalue: dataset.y[ii] 108 - }); 109 - } 120 + for (var ii = 0; ii < dataset.data.length; ii++) { 121 + g.append('path') 122 + .style('fill', dataset.color[ii % dataset.color.length]) 123 + .style('opacity', '0.15') 124 + .attr('d', area(dataset.data[ii])); 110 125 111 126 g.append('path') 112 - .datum(data) 113 127 .attr('class', 'line') 114 - .style('stroke', dataset.color) 115 - .attr('d', line); 128 + .attr('d', line(dataset.data[ii])); 116 129 117 130 g.selectAll('dot') 118 - .data(data) 131 + .data(dataset.events[ii]) 119 132 .enter() 120 133 .append('circle') 121 134 .attr('class', 'point') 122 135 .attr('r', 3) 123 - .attr('cx', function(d) { return x(d.xvalue); }) 124 - .attr('cy', function(d) { return y(d.yvalue); }) 136 + .attr('cx', function(d) { return x(to_date(d.x)); }) 137 + .attr('cy', function(d) { return y(d.y1); }) 125 138 .on('mouseover', function(d) { 126 - var d_y = d.xvalue.getFullYear(); 139 + var dd = to_date(d.x); 140 + 141 + var d_y = dd.getFullYear(); 127 142 128 143 // NOTE: Javascript months are zero-based. See PHI1017. 129 - var d_m = d.xvalue.getMonth() + 1; 144 + var d_m = dd.getMonth() + 1; 130 145 131 - var d_d = d.xvalue.getDate(); 146 + var d_d = dd.getDate(); 132 147 133 148 div 134 - .html(d_y + '-' + d_m + '-' + d_d + ': ' + d.yvalue) 149 + .html(d_y + '-' + d_m + '-' + d_d + ': ' + d.y1) 135 150 .style('opacity', 0.9) 136 151 .style('left', (d3.event.pageX - 60) + 'px') 137 152 .style('top', (d3.event.pageY - 38) + 'px'); ··· 139 154 .on('mouseout', function() { 140 155 div.style('opacity', 0); 141 156 }); 142 - } 143 157 144 - g.append('g') 145 - .attr('class', 'x axis') 146 - .attr('transform', css_function('translate', 0, size.height)) 147 - .call(xAxis); 158 + } 159 + }, 148 160 149 - g.append('g') 150 - .attr('class', 'y axis') 151 - .attr('transform', css_function('translate', 0, 0)) 152 - .call(yAxis); 161 + _newDate: function(epoch) { 162 + return new Date(epoch * 1000); 153 163 } 164 + 154 165 } 155 166 156 167 });