diff --git a/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php b/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php index 95392a029f..4a91aad635 100644 --- a/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php +++ b/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php @@ -1,170 +1,167 @@ 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]); } $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; } 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; } $events = array(); foreach ($raw_points as $function_idx => $points) { $event_list = array(); foreach ($points as $point) { $event_list[] = $point; } $events[] = $event_list; } $wire_labels = array(); foreach ($functions as $function_key => $function) { $label = $function->getFunctionLabel(); - - $label->setName(pht('Important Data %s', $function_key)); - $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)); } } diff --git a/src/applications/fact/engine/PhabricatorChartEngine.php b/src/applications/fact/engine/PhabricatorChartEngine.php index d0ccca2034..f723633d6a 100644 --- a/src/applications/fact/engine/PhabricatorChartEngine.php +++ b/src/applications/fact/engine/PhabricatorChartEngine.php @@ -1,48 +1,97 @@ viewer = $viewer; return $this; } final public function getViewer() { return $this->viewer; } + final protected function setEngineParameter($key, $value) { + $this->engineParameters[$key] = $value; + return $this; + } + + final protected function getEngineParameter($key, $default = null) { + return idx($this->engineParameters, $key, $default); + } + + final protected function getEngineParameters() { + return $this->engineParameters; + } + + final public static function newFromChart(PhabricatorFactChart $chart) { + $engine_key = $chart->getChartParameter(self::KEY_ENGINE); + + $engine_map = self::getAllChartEngines(); + if (!isset($engine_map[$engine_key])) { + throw new Exception( + pht( + 'Chart uses unknown engine key ("%s") and can not be rendered.', + $engine_key)); + } + + return clone id($engine_map[$engine_key]); + } + + final public static function getAllChartEngines() { + return id(new PhutilClassMapQuery()) + ->setAncestorClass(__CLASS__) + ->setUniqueMethod('getChartEngineKey') + ->execute(); + } + final public function getChartEngineKey() { return $this->getPhobjectClassConstant('CHARTENGINEKEY', 32); } - abstract protected function newChart(); + final public function buildChart(PhabricatorFactChart $chart) { + $map = $chart->getChartParameter(self::KEY_PARAMETERS, array()); + return $this->newChart($chart, $map); + } + + abstract protected function newChart(PhabricatorFactChart $chart, array $map); - final public function buildChart() { + final public function buildChartPanel() { $viewer = $this->getViewer(); - $chart = $this->newChart(); + $parameters = $this->getEngineParameters(); + + $chart = id(new PhabricatorFactChart()) + ->setChartParameter(self::KEY_ENGINE, $this->getChartEngineKey()) + ->setChartParameter(self::KEY_PARAMETERS, $this->getEngineParameters()); $rendering_engine = id(new PhabricatorChartRenderingEngine()) ->setViewer($viewer) ->setChart($chart); - return $rendering_engine->getStoredChart(); - } - - final public function buildChartPanel() { - $chart = $this->buildChart(); + $chart = $rendering_engine->getStoredChart(); $panel_type = id(new PhabricatorDashboardChartPanelType()) ->getPanelTypeKey(); $chart_panel = id(new PhabricatorDashboardPanel()) ->setPanelType($panel_type) ->setProperty('chartKey', $chart->getChartKey()); return $chart_panel; } + final protected function newFunction($name /* , ... */) { + $argv = func_get_args(); + return id(new PhabricatorComposeChartFunction()) + ->setArguments(array($argv)); + } + } diff --git a/src/applications/fact/engine/PhabricatorChartRenderingEngine.php b/src/applications/fact/engine/PhabricatorChartRenderingEngine.php index f241d45628..b328241ea6 100644 --- a/src/applications/fact/engine/PhabricatorChartRenderingEngine.php +++ b/src/applications/fact/engine/PhabricatorChartRenderingEngine.php @@ -1,209 +1,213 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setChart(PhabricatorFactChart $chart) { $this->chart = $chart; return $this; } public function getChart() { return $this->chart; } public function loadChart($chart_key) { $chart = id(new PhabricatorFactChart())->loadOneWhere( 'chartKey = %s', $chart_key); if ($chart) { $this->setChart($chart); } return $chart; } public static function getChartURI($chart_key) { return id(new PhabricatorFactChart()) ->setChartKey($chart_key) ->getURI(); } public function getStoredChart() { if (!$this->storedChart) { $chart = $this->getChart(); $chart_key = $chart->getChartKey(); if (!$chart_key) { $chart_key = $chart->newChartKey(); $stored_chart = id(new PhabricatorFactChart())->loadOneWhere( 'chartKey = %s', $chart_key); if ($stored_chart) { $chart = $stored_chart; } else { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); try { $chart->save(); } catch (AphrontDuplicateKeyQueryException $ex) { $chart = id(new PhabricatorFactChart())->loadOneWhere( 'chartKey = %s', $chart_key); if (!$chart) { throw new Exception( pht( 'Failed to load chart with key "%s" after key collision. '. 'This should not be possible.', $chart_key)); } } unset($unguarded); } $this->setChart($chart); } $this->storedChart = $chart; } return $this->storedChart; } public function newChartView() { $chart = $this->getStoredChart(); $chart_key = $chart->getChartKey(); $chart_node_id = celerity_generate_unique_node_id(); $chart_view = phutil_tag( 'div', array( 'id' => $chart_node_id, 'class' => 'chart-hardpoint', )); $data_uri = urisprintf('/fact/chart/%s/draw/', $chart_key); Javelin::initBehavior( 'line-chart', array( 'chartNodeID' => $chart_node_id, 'dataURI' => (string)$data_uri, )); return $chart_view; } public function newChartData() { $chart = $this->getStoredChart(); $chart_key = $chart->getChartKey(); + $chart_engine = PhabricatorChartEngine::newFromChart($chart) + ->setViewer($this->getViewer()); + $chart_engine->buildChart($chart); + $datasets = $chart->getDatasets(); $functions = array(); foreach ($datasets as $dataset) { foreach ($dataset->getFunctions() as $function) { $functions[] = $function; } } $subfunctions = array(); foreach ($functions as $function) { foreach ($function->getSubfunctions() as $subfunction) { $subfunctions[] = $subfunction; } } foreach ($subfunctions as $subfunction) { $subfunction->loadData(); } $domain = $this->getDomain($functions); $axis = id(new PhabricatorChartAxis()) ->setMinimumValue($domain->getMin()) ->setMaximumValue($domain->getMax()); $data_query = id(new PhabricatorChartDataQuery()) ->setMinimumValue($domain->getMin()) ->setMaximumValue($domain->getMax()) ->setLimit(2000); $wire_datasets = array(); $ranges = array(); foreach ($datasets as $dataset) { $display_data = $dataset->getChartDisplayData($data_query); $ranges[] = $display_data->getRange(); $wire_datasets[] = $display_data->getWireData(); } $range = $this->getRange($ranges); $chart_data = array( 'datasets' => $wire_datasets, 'xMin' => $domain->getMin(), 'xMax' => $domain->getMax(), 'yMin' => $range->getMin(), 'yMax' => $range->getMax(), ); return $chart_data; } private function getDomain(array $functions) { $domains = array(); foreach ($functions as $function) { $domains[] = $function->getDomain(); } $domain = PhabricatorChartInterval::newFromIntervalList($domains); // If we don't have any domain data from the actual functions, pick a // plausible domain automatically. if ($domain->getMax() === null) { $domain->setMax(PhabricatorTime::getNow()); } if ($domain->getMin() === null) { $domain->setMin($domain->getMax() - phutil_units('365 days in seconds')); } return $domain; } private function getRange(array $ranges) { $range = PhabricatorChartInterval::newFromIntervalList($ranges); // Start the Y axis at 0 unless the chart has negative values. $min = $range->getMin(); if ($min === null || $min >= 0) { $range->setMin(0); } // If there's no maximum value, just pick a plausible default. $max = $range->getMax(); if ($max === null) { $range->setMax($range->getMin() + 100); } return $range; } } diff --git a/src/applications/fact/storage/PhabricatorFactChart.php b/src/applications/fact/storage/PhabricatorFactChart.php index 515b5f0a72..0fb04ccaa5 100644 --- a/src/applications/fact/storage/PhabricatorFactChart.php +++ b/src/applications/fact/storage/PhabricatorFactChart.php @@ -1,109 +1,88 @@ array( 'chartParameters' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'chartKey' => 'bytes12', ), self::CONFIG_KEY_SCHEMA => array( 'key_chart' => array( 'columns' => array('chartKey'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function setChartParameter($key, $value) { $this->chartParameters[$key] = $value; return $this; } public function getChartParameter($key, $default = null) { return idx($this->chartParameters, $key, $default); } public function newChartKey() { $digest = serialize($this->chartParameters); $digest = PhabricatorHash::digestForIndex($digest); return $digest; } public function save() { if ($this->getID()) { throw new Exception( pht( 'Chart configurations are not mutable. You can not update or '. 'overwrite an existing chart configuration.')); } $this->chartKey = $this->newChartKey(); return parent::save(); } - public function setDatasets(array $datasets) { + public function attachDatasets(array $datasets) { assert_instances_of($datasets, 'PhabricatorChartDataset'); - - $dataset_list = array(); - foreach ($datasets as $dataset) { - $dataset_list[] = $dataset->toDictionary(); - } - - $this->setChartParameter('datasets', $dataset_list); - $this->datasets = null; - + $this->datasets = $datasets; return $this; } public function getDatasets() { - if ($this->datasets === null) { - $this->datasets = $this->newDatasets(); - } - return $this->datasets; - } - - private function newDatasets() { - $datasets = $this->getChartParameter('datasets', array()); - - foreach ($datasets as $key => $dataset) { - $datasets[$key] = PhabricatorChartDataset::newFromDictionary($dataset); - } - - return $datasets; + return $this->assertAttached($this->datasets); } public function getURI() { return urisprintf('/fact/chart/%s/', $this->getChartKey()); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return PhabricatorPolicies::getMostOpenPolicy(); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } } diff --git a/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php b/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php index fd4a872bbd..35496330e8 100644 --- a/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php +++ b/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php @@ -1,67 +1,101 @@ projects = $projects; - - return $this; + $project_phids = mpull($projects, 'getPHID'); + return $this->setEngineParameter('projectPHIDs', $project_phids); } - public function getProjects() { - return $this->projects; - } + protected function newChart(PhabricatorFactChart $chart, array $map) { + $viewer = $this->getViewer(); + + $map = $map + array( + 'projectPHIDs' => array(), + ); - protected function newChart() { - if ($this->projects !== null) { - $project_phids = mpull($this->projects, 'getPHID'); + if ($map['projectPHIDs']) { + $projects = id(new PhabricatorProjectQuery()) + ->setViewer($viewer) + ->withPHIDs($map['projectPHIDs']) + ->execute(); + $project_phids = mpull($projects, 'getPHID'); } else { - $project_phids = null; + $project_phids = array(); } - $argvs = array(); + $functions = array(); if ($project_phids) { foreach ($project_phids as $project_phid) { - $argvs[] = array( + $function = $this->newFunction( 'accumulate', - array('fact', 'tasks.open-count.create.project', $project_phid), - ); - $argvs[] = array( + array('fact', 'tasks.open-count.create.project', $project_phid)); + + $function->getFunctionLabel() + ->setName(pht('Tasks Created')) + ->setColor('rgba(0, 0, 200, 1)') + ->setFillColor('rgba(0, 0, 200, 0.15)'); + + $functions[] = $function; + + + $function = $this->newFunction( 'accumulate', - array('fact', 'tasks.open-count.status.project', $project_phid), - ); - $argvs[] = array( + array('fact', 'tasks.open-count.status.project', $project_phid)); + + $function->getFunctionLabel() + ->setName(pht('Tasks Closed / Reopened')) + ->setColor('rgba(200, 0, 200, 1)') + ->setFillColor('rgba(200, 0, 200, 0.15)'); + + $functions[] = $function; + + + $function = $this->newFunction( 'accumulate', - array('fact', 'tasks.open-count.assign.project', $project_phid), - ); + array('fact', 'tasks.open-count.assign.project', $project_phid)); + + $function->getFunctionLabel() + ->setName(pht('Tasks Rescoped')) + ->setColor('rgba(0, 200, 200, 1)') + ->setFillColor('rgba(0, 200, 200, 0.15)'); + + $functions[] = $function; } } else { - $argvs[] = array('accumulate', array('fact', 'tasks.open-count.create')); - $argvs[] = array('accumulate', array('fact', 'tasks.open-count.status')); - } + $function = $this->newFunction( + 'accumulate', + array('fact', 'tasks.open-count.create')); - $functions = array(); - foreach ($argvs as $argv) { - $functions[] = id(new PhabricatorComposeChartFunction()) - ->setArguments(array($argv)); + $function->getFunctionLabel() + ->setName(pht('Tasks Created')) + ->setColor('rgba(0, 200, 200, 1)') + ->setFillColor('rgba(0, 200, 200, 0.15)'); + + $functions[] = $function; + + $function = $this->newFunction( + 'accumulate', + array('fact', 'tasks.open-count.status')); + + $function->getFunctionLabel() + ->setName(pht('Tasks Closed / Reopened')) + ->setColor('rgba(200, 0, 200, 1)') + ->setFillColor('rgba(200, 0, 200, 0.15)'); + + $functions[] = $function; } $datasets = array(); $datasets[] = id(new PhabricatorChartStackedAreaDataset()) ->setFunctions($functions); - $chart = id(new PhabricatorFactChart()) - ->setDatasets($datasets); - - return $chart; + $chart->attachDatasets($datasets); } }