diff --git a/src/applications/fact/chart/PhabricatorChartDataset.php b/src/applications/fact/chart/PhabricatorChartDataset.php index a794834229..093a742077 100644 --- a/src/applications/fact/chart/PhabricatorChartDataset.php +++ b/src/applications/fact/chart/PhabricatorChartDataset.php @@ -1,109 +1,102 @@ getPhobjectClassConstant('DATASETKEY', 32); } final public function getFunctions() { return $this->functions; } final public function setFunctions(array $functions) { assert_instances_of($functions, 'PhabricatorComposeChartFunction'); $this->functions = $functions; return $this; } 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( 'type' => 'string', 'functions' => 'list', )); $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 = id(clone $types[$dataset_type]); $functions = array(); foreach ($map['functions'] as $map) { $functions[] = PhabricatorChartFunction::newFromDictionary($map); } $dataset->setFunctions($functions); return $dataset; } - final public function toDictionary() { - return array( - 'type' => $this->getDatasetTypeKey(), - 'functions' => mpull($this->getFunctions(), 'toDictionary'), - ); - } - final public function getChartDisplayData( PhabricatorChartDataQuery $data_query) { return $this->newChartDisplayData($data_query); } abstract protected function newChartDisplayData( PhabricatorChartDataQuery $data_query); final public function getTabularDisplayData( PhabricatorChartDataQuery $data_query) { $results = array(); $functions = $this->getFunctions(); foreach ($functions as $function) { $datapoints = $function->newDatapoints($data_query); $refs = $function->getDataRefs(ipull($datapoints, 'x')); foreach ($datapoints as $key => $point) { $x = $point['x']; if (isset($refs[$x])) { $xrefs = $refs[$x]; } else { $xrefs = array(); } $datapoints[$key]['refs'] = $xrefs; } $results[] = array( 'data' => $datapoints, ); } return id(new PhabricatorChartDisplayData()) ->setWireData($results); } } diff --git a/src/applications/fact/chart/PhabricatorChartFunction.php b/src/applications/fact/chart/PhabricatorChartFunction.php index ef5c5641bf..3ddcd6aec0 100644 --- a/src/applications/fact/chart/PhabricatorChartFunction.php +++ b/src/applications/fact/chart/PhabricatorChartFunction.php @@ -1,254 +1,251 @@ getPhobjectClassConstant('FUNCTIONKEY', 32); } final public static function getAllFunctions() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getFunctionKey') ->execute(); } final public function setArguments(array $arguments) { $parser = $this->getArgumentParser(); $parser->setRawArguments($arguments); $specs = $this->newArguments(); if (!is_array($specs)) { throw new Exception( pht( 'Expected "newArguments()" in class "%s" to return a list of '. 'argument specifications, got %s.', get_class($this), phutil_describe_type($specs))); } assert_instances_of($specs, 'PhabricatorChartFunctionArgument'); foreach ($specs as $spec) { $parser->addArgument($spec); } $parser->setHaveAllArguments(true); $parser->parseArguments(); return $this; } public function setFunctionLabel(PhabricatorChartFunctionLabel $label) { $this->functionLabel = $label; return $this; } public function getFunctionLabel() { if (!$this->functionLabel) { $this->functionLabel = id(new PhabricatorChartFunctionLabel()) ->setName(pht('Unlabeled Function')) ->setColor('rgba(255, 0, 0, 1)') ->setFillColor('rgba(255, 0, 0, 0.15)'); } return $this->functionLabel; } + final public function getKey() { + return $this->getFunctionLabel()->getKey(); + } + 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 array( - 'function' => $this->getFunctionKey(), - 'arguments' => $this->getArgumentParser()->getRawArguments(), - ); - } - public function getSubfunctions() { $result = array(); $result[] = $this; foreach ($this->getFunctionArguments() as $argument) { foreach ($argument->getSubfunctions() as $subfunction) { $result[] = $subfunction; } } return $result; } public function getFunctionArguments() { $results = array(); $parser = $this->getArgumentParser(); foreach ($parser->getAllArguments() as $argument) { if ($argument->getType() !== 'function') { continue; } $name = $argument->getName(); $value = $this->getArgument($name); if (!is_array($value)) { $results[] = $value; } else { foreach ($value as $arg_value) { $results[] = $arg_value; } } } return $results; } public function newDatapoints(PhabricatorChartDataQuery $query) { $xv = $this->newInputValues($query); if ($xv === null) { $xv = $this->newDefaultInputValues($query); } $xv = $query->selectInputValues($xv); $n = count($xv); $yv = $this->evaluateFunction($xv); $points = array(); for ($ii = 0; $ii < $n; $ii++) { $y = $yv[$ii]; if ($y === null) { continue; } $points[] = array( 'x' => $xv[$ii], 'y' => $y, ); } return $points; } abstract protected function newArguments(); final protected function newArgument() { return new PhabricatorChartFunctionArgument(); } final protected function getArgument($key) { return $this->getArgumentParser()->getArgumentValue($key); } final protected function getArgumentParser() { if (!$this->argumentParser) { $parser = id(new PhabricatorChartFunctionArgumentParser()) ->setFunction($this); $this->argumentParser = $parser; } return $this->argumentParser; } abstract public function evaluateFunction(array $xv); abstract public function getDataRefs(array $xv); abstract public function loadRefs(array $refs); public function getDomain() { return null; } public function newInputValues(PhabricatorChartDataQuery $query) { return null; } public function loadData() { return; } protected function newDefaultInputValues(PhabricatorChartDataQuery $query) { $x_min = $query->getMinimumValue(); $x_max = $query->getMaximumValue(); $limit = $query->getLimit(); return $this->newLinearSteps($x_min, $x_max, $limit); } protected function newLinearSteps($src, $dst, $count) { $count = (int)$count; $src = (int)$src; $dst = (int)$dst; if ($count === 0) { throw new Exception( pht('Can not generate zero linear steps between two values!')); } if ($src === $dst) { return array($src); } if ($count === 1) { return array($src); } $is_reversed = ($src > $dst); if ($is_reversed) { $min = (double)$dst; $max = (double)$src; } else { $min = (double)$src; $max = (double)$dst; } $step = (double)($max - $min) / (double)($count - 1); $steps = array(); for ($cursor = $min; $cursor <= $max; $cursor += $step) { $x = (int)round($cursor); if (isset($steps[$x])) { continue; } $steps[$x] = $x; } $steps = array_values($steps); if ($is_reversed) { $steps = array_reverse($steps); } return $steps; } } diff --git a/src/applications/fact/chart/PhabricatorChartFunctionLabel.php b/src/applications/fact/chart/PhabricatorChartFunctionLabel.php index ad85c49b71..fa3f65aa67 100644 --- a/src/applications/fact/chart/PhabricatorChartFunctionLabel.php +++ b/src/applications/fact/chart/PhabricatorChartFunctionLabel.php @@ -1,56 +1,67 @@ key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setColor($color) { $this->color = $color; return $this; } public function getColor() { return $this->color; } public function setIcon($icon) { $this->icon = $icon; return $this; } public function getIcon() { return $this->icon; } public function setFillColor($fill_color) { $this->fillColor = $fill_color; return $this; } public function getFillColor() { return $this->fillColor; } public function toWireFormat() { return array( + 'key' => $this->getKey(), 'name' => $this->getName(), 'color' => $this->getColor(), 'icon' => $this->getIcon(), 'fillColor' => $this->getFillColor(), ); } } diff --git a/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php b/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php index 4d30cd767b..2ba08ea1c9 100644 --- a/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php +++ b/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php @@ -1,226 +1,300 @@ getFunctions(); + private $stacks; - $reversed_functions = array_reverse($functions, true); + public function setStacks(array $stacks) { + $this->stacks = $stacks; + return $this; + } - $function_points = array(); - foreach ($reversed_functions as $function_idx => $function) { - $function_points[$function_idx] = array(); + public function getStacks() { + return $this->stacks; + } - $datapoints = $function->newDatapoints($data_query); - foreach ($datapoints as $point) { - $x_value = $point['x']; - $function_points[$function_idx][$x_value] = $point; - } - } + protected function newChartDisplayData( + PhabricatorChartDataQuery $data_query) { - $raw_points = $function_points; + $functions = $this->getFunctions(); + $functions = mpull($functions, null, 'getKey'); - // 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. + $stacks = $this->getStacks(); - $must_define = array(); - foreach ($function_points as $function_idx => $points) { - foreach ($points as $x => $point) { - $must_define[$x] = $x; - } + if (!$stacks) { + $stacks = array( + array_reverse(array_keys($functions), true), + ); } - ksort($must_define); - foreach ($reversed_functions as $function_idx => $function) { - $missing = array(); - foreach ($must_define as $x) { - if (!isset($function_points[$function_idx][$x])) { - $missing[$x] = true; + $series = array(); + $raw_points = array(); + + foreach ($stacks as $stack) { + $stack_functions = array_select_keys($functions, $stack); + + $function_points = $this->getFunctionDatapoints( + $data_query, + $stack_functions); + + $stack_points = $function_points; + + $function_points = $this->getGeometry( + $data_query, + $function_points); + + $baseline = array(); + foreach ($function_points as $function_idx => $points) { + $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($stack_points[$function_idx][$x])) { + $stack_points[$function_idx][$x]['y1'] = $y1; + } } - } - if (!$missing) { - continue; + $series[$function_idx] = $bounds; } - $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']; - } + $raw_points += $stack_points; + } - $function_points[$function_idx][$x] = array( - 'x' => $x, - 'y' => $y, - ); - } + $series = array_select_keys($series, array_keys($functions)); + $series = array_values($series); - ksort($function_points[$function_idx]); - } + $raw_points = array_select_keys($raw_points, array_keys($functions)); + $raw_points = array_values($raw_points); $range_min = null; $range_max = null; - $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; - } + foreach ($series as $geometry_list) { + foreach ($geometry_list as $geometry_item) { + $y0 = $geometry_item['y0']; + $y1 = $geometry_item['y1']; if ($range_min === null) { $range_min = $y0; } $range_min = min($range_min, $y0, $y1); if ($range_max === null) { $range_max = $y1; } $range_max = max($range_max, $y0, $y1); } - - $series[] = $bounds; } - $series = array_reverse($series); - // We're going to group multiple events into a single point if they have // X values that are very close to one another. // // If the Y values are also close to one another (these points are near // one another in a horizontal line), it can be hard to select any // individual point with the mouse. // // Even if the Y values are not close together (the points are on a // fairly steep slope up or down), it's usually better to be able to // mouse over a single point at the top or bottom of the slope and get // a summary of what's going on. $domain_max = $data_query->getMaximumValue(); $domain_min = $data_query->getMinimumValue(); $resolution = ($domain_max - $domain_min) / 100; $events = array(); foreach ($raw_points as $function_idx => $points) { $event_list = array(); $event_group = array(); $head_event = null; foreach ($points as $point) { $x = $point['x']; if ($head_event === null) { // We don't have any points yet, so start a new group. $head_event = $x; $event_group[] = $point; } else if (($x - $head_event) <= $resolution) { // This point is close to the first point in this group, so // add it to the existing group. $event_group[] = $point; } else { // This point is not close to the first point in the group, // so create a new group. $event_list[] = $event_group; $head_event = $x; $event_group = array($point); } } if ($event_group) { $event_list[] = $event_group; } $event_spec = array(); foreach ($event_list as $key => $event_points) { // NOTE: We're using the last point as the representative point so // that you can learn about a section of a chart by hovering over // the point to right of the section, which is more intuitive than // other points. $event = last($event_points); $event = $event + array( 'n' => count($event_points), ); $event_list[$key] = $event; } $events[] = $event_list; } $wire_labels = array(); foreach ($functions as $function_key => $function) { $label = $function->getFunctionLabel(); $wire_labels[] = $label->toWireFormat(); } $result = array( 'type' => $this->getDatasetTypeKey(), 'data' => $series, 'events' => $events, 'labels' => $wire_labels, ); return id(new PhabricatorChartDisplayData()) ->setWireData($result) ->setRange(new PhabricatorChartInterval($range_min, $range_max)); } + private function getAllXValuesAsMap( + PhabricatorChartDataQuery $data_query, + array $point_lists) { + + // 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(); + + $min = $data_query->getMinimumValue(); + $max = $data_query->getMaximumValue(); + $must_define[$max] = $max; + $must_define[$min] = $min; + + foreach ($point_lists as $point_list) { + foreach ($point_list as $x => $point) { + $must_define[$x] = $x; + } + } + + ksort($must_define); + + return $must_define; + } + + private function getFunctionDatapoints( + PhabricatorChartDataQuery $data_query, + array $functions) { + + assert_instances_of($functions, 'PhabricatorChartFunction'); + + $points = array(); + foreach ($functions as $idx => $function) { + $points[$idx] = array(); + + $datapoints = $function->newDatapoints($data_query); + foreach ($datapoints as $point) { + $x_value = $point['x']; + $points[$idx][$x_value] = $point; + } + } + + return $points; + } + + private function getGeometry( + PhabricatorChartDataQuery $data_query, + array $point_lists) { + + $must_define = $this->getAllXValuesAsMap($data_query, $point_lists); + + foreach ($point_lists as $idx => $points) { + + $missing = array(); + foreach ($must_define as $x) { + if (!isset($points[$x])) { + $missing[$x] = true; + } + } + + if (!$missing) { + continue; + } + + $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 = $points[$xmin]['y']; + } + + $point_lists[$idx][$x] = array( + 'x' => $x, + 'y' => $y, + ); + } + + ksort($point_lists[$idx]); + } + + return $point_lists; + } } diff --git a/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php b/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php index 2dbb2a6a16..8dda7cde16 100644 --- a/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php +++ b/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php @@ -1,130 +1,168 @@ setEngineParameter('projectPHIDs', $project_phids); } protected function newChart(PhabricatorFactChart $chart, array $map) { $viewer = $this->getViewer(); $map = $map + array( 'projectPHIDs' => array(), ); if ($map['projectPHIDs']) { $projects = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withPHIDs($map['projectPHIDs']) ->execute(); $project_phids = mpull($projects, 'getPHID'); } else { $project_phids = array(); } $functions = array(); + $stacks = array(); + if ($project_phids) { foreach ($project_phids as $project_phid) { $function = $this->newFunction( array( 'accumulate', array( 'compose', array('fact', 'tasks.open-count.assign.project', $project_phid), array('min', 0), ), )); $function->getFunctionLabel() + ->setKey('moved-in') ->setName(pht('Tasks Moved Into Project')) ->setColor('rgba(128, 128, 200, 1)') ->setFillColor('rgba(128, 128, 200, 0.15)'); $functions[] = $function; + $function = $this->newFunction( + array( + 'accumulate', + array( + 'compose', + array('fact', 'tasks.open-count.status.project', $project_phid), + array('min', 0), + ), + )); + + $function->getFunctionLabel() + ->setKey('reopened') + ->setName(pht('Tasks Reopened')) + ->setColor('rgba(128, 128, 200, 1)') + ->setFillColor('rgba(128, 128, 200, 0.15)'); + + $functions[] = $function; + $function = $this->newFunction( array( 'accumulate', array('fact', 'tasks.open-count.create.project', $project_phid), )); $function->getFunctionLabel() + ->setKey('created') ->setName(pht('Tasks Created')) ->setColor('rgba(0, 0, 200, 1)') ->setFillColor('rgba(0, 0, 200, 0.15)'); $functions[] = $function; $function = $this->newFunction( array( 'accumulate', array( 'compose', - array('fact', 'tasks.open-count.assign.project', $project_phid), + array('fact', 'tasks.open-count.status.project', $project_phid), array('max', 0), ), )); $function->getFunctionLabel() - ->setName(pht('Tasks Moved Out of Project')) - ->setColor('rgba(128, 200, 128, 1)') - ->setFillColor('rgba(128, 200, 128, 0.15)'); + ->setKey('closed') + ->setName(pht('Tasks Closed')) + ->setColor('rgba(0, 200, 0, 1)') + ->setFillColor('rgba(0, 200, 0, 0.15)'); $functions[] = $function; $function = $this->newFunction( array( 'accumulate', - array('fact', 'tasks.open-count.status.project', $project_phid), + array( + 'compose', + array('fact', 'tasks.open-count.assign.project', $project_phid), + array('max', 0), + ), )); $function->getFunctionLabel() - ->setName(pht('Tasks Closed')) - ->setColor('rgba(0, 200, 0, 1)') - ->setFillColor('rgba(0, 200, 0, 0.15)'); + ->setKey('moved-out') + ->setName(pht('Tasks Moved Out of Project')) + ->setColor('rgba(128, 200, 128, 1)') + ->setFillColor('rgba(128, 200, 128, 0.15)'); $functions[] = $function; + + $stacks[] = array('created', 'reopened', 'moved-in'); + $stacks[] = array('closed', 'moved-out'); } } else { $function = $this->newFunction( array( 'accumulate', array('fact', 'tasks.open-count.create'), )); $function->getFunctionLabel() - ->setName(pht('Tasks Created')) + ->setKey('open') + ->setName(pht('Open Tasks')) ->setColor('rgba(0, 0, 200, 1)') ->setFillColor('rgba(0, 0, 200, 0.15)'); $functions[] = $function; $function = $this->newFunction( array( 'accumulate', array('fact', 'tasks.open-count.status'), )); $function->getFunctionLabel() - ->setName(pht('Tasks Closed')) + ->setKey('closed') + ->setName(pht('Closed Tasks')) ->setColor('rgba(0, 200, 0, 1)') ->setFillColor('rgba(0, 200, 0, 0.15)'); $functions[] = $function; } $datasets = array(); - $datasets[] = id(new PhabricatorChartStackedAreaDataset()) + $dataset = id(new PhabricatorChartStackedAreaDataset()) ->setFunctions($functions); + if ($stacks) { + $dataset->setStacks($stacks); + } + + $datasets[] = $dataset; $chart->attachDatasets($datasets); } }