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 @@ -2653,6 +2653,8 @@ 'PhabricatorChangesetResponse' => 'infrastructure/diff/PhabricatorChangesetResponse.php', 'PhabricatorChartAxis' => 'applications/fact/chart/PhabricatorChartAxis.php', 'PhabricatorChartDataQuery' => 'applications/fact/chart/PhabricatorChartDataQuery.php', + 'PhabricatorChartDataset' => 'applications/fact/chart/PhabricatorChartDataset.php', + 'PhabricatorChartEngine' => 'applications/fact/engine/PhabricatorChartEngine.php', 'PhabricatorChartFunction' => 'applications/fact/chart/PhabricatorChartFunction.php', 'PhabricatorChartFunctionArgument' => 'applications/fact/chart/PhabricatorChartFunctionArgument.php', 'PhabricatorChartFunctionArgumentParser' => 'applications/fact/chart/PhabricatorChartFunctionArgumentParser.php', @@ -8640,6 +8642,8 @@ 'PhabricatorChangesetResponse' => 'AphrontProxyResponse', 'PhabricatorChartAxis' => 'Phobject', 'PhabricatorChartDataQuery' => 'Phobject', + 'PhabricatorChartDataset' => 'Phobject', + 'PhabricatorChartEngine' => 'Phobject', 'PhabricatorChartFunction' => 'Phobject', 'PhabricatorChartFunctionArgument' => 'Phobject', 'PhabricatorChartFunctionArgumentParser' => 'Phobject', 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,9 @@ return array( '/fact/' => array( '' => 'PhabricatorFactHomeController', - '(?chart|draw)/' => 'PhabricatorFactChartController', + 'chart/' => 'PhabricatorFactChartController', + 'chart/(?P[^/]+)/(?:(?Pdraw)/)?' => + 'PhabricatorFactChartController', 'object/(?[^/]+)/' => 'PhabricatorFactObjectController', ), ); diff --git a/src/applications/fact/chart/PhabricatorChartDataset.php b/src/applications/fact/chart/PhabricatorChartDataset.php new file mode 100644 --- /dev/null +++ b/src/applications/fact/chart/PhabricatorChartDataset.php @@ -0,0 +1,37 @@ +function; + } + + public static function newFromDictionary(array $map) { + PhutilTypeSpec::checkMap( + $map, + array( + 'function' => 'list', + )); + + $dataset = new self(); + + $dataset->function = id(new PhabricatorComposeChartFunction()) + ->setArguments(array($map['function'])); + + 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()); + + return array( + 'function' => $function_raw, + ); + } + +} 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,6 +43,10 @@ return $this; } + public function toDictionary() { + return $this->getArgumentParser()->getRawArguments(); + } + public function getSubfunctions() { $result = array(); $result[] = $this; diff --git a/src/applications/fact/chart/PhabricatorChartFunctionArgumentParser.php b/src/applications/fact/chart/PhabricatorChartFunctionArgumentParser.php --- a/src/applications/fact/chart/PhabricatorChartFunctionArgumentParser.php +++ b/src/applications/fact/chart/PhabricatorChartFunctionArgumentParser.php @@ -103,6 +103,10 @@ return array_values($this->argumentMap); } + public function getRawArguments() { + return $this->rawArguments; + } + public function parseArguments() { $have_count = count($this->rawArguments); $want_count = count($this->argumentMap); 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,13 +5,60 @@ public function handleRequest(AphrontRequest $request) { $viewer = $request->getViewer(); + $chart_key = $request->getURIData('chartKey'); + if ($chart_key === null) { + return $this->newDemoChart(); + } + + $chart = id(new PhabricatorFactChart())->loadOneWhere( + 'chartKey = %s', + $chart_key); + if (!$chart) { + return new Aphront404Response(); + } + + $engine = id(new PhabricatorChartEngine()) + ->setViewer($viewer) + ->setChart($chart); + // 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'); + // TODO: For now, always pull the data. We'll throw it away if we're just + // drawing the frame, but this makes errors easier to debug. + $chart_data = $engine->newChartData(); + + if ($is_draw_mode) { + return id(new AphrontAjaxResponse())->setContent($chart_data); + } + + $chart_view = $engine->newChartView(); + return $this->newChartResponse($chart_view); + } + + private function newChartResponse($chart_view) { + $box = id(new PHUIObjectBoxView()) + ->setHeaderText(pht('Chart')) + ->appendChild($chart_view); + + $crumbs = $this->buildApplicationCrumbs() + ->addTextCrumb(pht('Chart')) + ->setBorder(true); + + $title = pht('Chart'); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($box); + } + + private function newDemoChart() { + $viewer = $this->getViewer(); + $argvs = array(); $argvs[] = array('fact', 'tasks.count.create'); @@ -40,165 +87,24 @@ array('shift', 800), ); - $functions = array(); - foreach ($argvs as $argv) { - $functions[] = id(new PhabricatorComposeChartFunction()) - ->setArguments(array($argv)); - } - - $subfunctions = array(); - foreach ($functions as $function) { - foreach ($function->getSubfunctions() as $subfunction) { - $subfunctions[] = $subfunction; - } - } - - foreach ($subfunctions as $subfunction) { - $subfunction->loadData(); - } - - list($domain_min, $domain_max) = $this->getDomain($functions); - - $axis = id(new PhabricatorChartAxis()) - ->setMinimumValue($domain_min) - ->setMaximumValue($domain_max); - - $data_query = id(new PhabricatorChartDataQuery()) - ->setMinimumValue($domain_min) - ->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; - foreach ($datasets as $dataset) { - if (!$dataset['y']) { - continue; - } - - $y_min = min($y_min, min($dataset['y'])); - $y_max = max($y_max, max($dataset['y'])); - } - - $chart_data = array( - 'datasets' => $datasets, - 'xMin' => $domain_min, - 'xMax' => $domain_max, - 'yMin' => $y_min, - 'yMax' => $y_max, - ); - - // TODO: Move this back up, it's just down here for now to make - // debugging easier so the main page throws a more visible exception when - // something goes wrong. - if ($is_chart_mode) { - return $this->newChartResponse(); - } - - 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( - 'chartNodeID' => $chart_node_id, - 'dataURI' => (string)$data_uri, - )); - - $box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Chart')) - ->appendChild($chart_view); - - $crumbs = $this->buildApplicationCrumbs() - ->addTextCrumb(pht('Chart')) - ->setBorder(true); - - $title = pht('Chart'); - - return $this->newPage() - ->setTitle($title) - ->setCrumbs($crumbs) - ->appendChild($box); - - } - - private function getDomain(array $functions) { - $domain_min_list = null; - $domain_max_list = null; - - foreach ($functions as $function) { - $domain = $function->getDomain(); - - list($function_min, $function_max) = $domain; - - if ($function_min !== null) { - $domain_min_list[] = $function_min; - } - - if ($function_max !== null) { - $domain_max_list[] = $function_max; - } - } - - $domain_min = null; - $domain_max = null; - - if ($domain_min_list) { - $domain_min = min($domain_min_list); - } - - if ($domain_max_list) { - $domain_max = max($domain_max_list); + foreach ($argvs as $argv) { + $datasets[] = PhabricatorChartDataset::newFromDictionary( + array( + 'function' => $argv, + )); } - // If we don't have any domain data from the actual functions, pick a - // plausible domain automatically. + $chart = id(new PhabricatorFactChart()) + ->setDatasets($datasets); - if ($domain_max === null) { - $domain_max = PhabricatorTime::getNow(); - } + $engine = id(new PhabricatorChartEngine()) + ->setViewer($viewer) + ->setChart($chart); - if ($domain_min === null) { - $domain_min = $domain_max - phutil_units('365 days in seconds'); - } + $chart = $engine->getStoredChart(); - return array($domain_min, $domain_max); + return id(new AphrontRedirectResponse())->setURI($chart->getURI()); } - } diff --git a/src/applications/fact/controller/PhabricatorFactChartController.php b/src/applications/fact/engine/PhabricatorChartEngine.php copy from src/applications/fact/controller/PhabricatorFactChartController.php copy to src/applications/fact/engine/PhabricatorChartEngine.php --- a/src/applications/fact/controller/PhabricatorFactChartController.php +++ b/src/applications/fact/engine/PhabricatorChartEngine.php @@ -1,49 +1,107 @@ getViewer(); + private $viewer; + private $chart; + private $storedChart; - // 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'); + public function setViewer(PhabricatorUser $viewer) { + $this->viewer = $viewer; + return $this; + } + + public function getViewer() { + return $this->viewer; + } - $argvs = array(); + public function setChart(PhabricatorFactChart $chart) { + $this->chart = $chart; + return $this; + } - $argvs[] = array('fact', 'tasks.count.create'); + public function getChart() { + return $this->chart; + } - $argvs[] = array('constant', 360); + 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); + } - $argvs[] = array('fact', 'tasks.open-count.create'); + $this->storedChart = $chart; + } - $argvs[] = array( - 'sum', + 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( - 'accumulate', - array('fact', 'tasks.count.create'), + 'id' => $chart_node_id, + 'style' => 'background: #ffffff; '. + 'height: 480px; ', ), + ''); + + $data_uri = urisprintf('/fact/chart/%s/draw/', $chart_key); + + Javelin::initBehavior( + 'line-chart', array( - 'accumulate', - array('fact', 'tasks.open-count.create'), - ), - ); + 'chartNodeID' => $chart_node_id, + 'dataURI' => (string)$data_uri, + )); - $argvs[] = array( - 'compose', - array('scale', 0.001), - array('cos'), - array('scale', 100), - array('shift', 800), - ); + return $chart_view; + } + + public function newChartData() { + $chart = $this->getStoredChart(); + $chart_key = $chart->getChartKey(); + + $datasets = $chart->getDatasets(); $functions = array(); - foreach ($argvs as $argv) { - $functions[] = id(new PhabricatorComposeChartFunction()) - ->setArguments(array($argv)); + foreach ($datasets as $dataset) { + $functions[] = $dataset->getFunction(); } $subfunctions = array(); @@ -107,54 +165,7 @@ 'yMax' => $y_max, ); - // TODO: Move this back up, it's just down here for now to make - // debugging easier so the main page throws a more visible exception when - // something goes wrong. - if ($is_chart_mode) { - return $this->newChartResponse(); - } - - 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( - 'chartNodeID' => $chart_node_id, - 'dataURI' => (string)$data_uri, - )); - - $box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Chart')) - ->appendChild($chart_view); - - $crumbs = $this->buildApplicationCrumbs() - ->addTextCrumb(pht('Chart')) - ->setBorder(true); - - $title = pht('Chart'); - - return $this->newPage() - ->setTitle($title) - ->setCrumbs($crumbs) - ->appendChild($box); - + return $chart_data; } private function getDomain(array $functions) { @@ -200,5 +211,4 @@ return array($domain_min, $domain_max); } - } diff --git a/src/applications/fact/storage/PhabricatorFactChart.php b/src/applications/fact/storage/PhabricatorFactChart.php --- a/src/applications/fact/storage/PhabricatorFactChart.php +++ b/src/applications/fact/storage/PhabricatorFactChart.php @@ -7,6 +7,8 @@ protected $chartKey; protected $chartParameters = array(); + private $datasets; + protected function getConfiguration() { return array( self::CONFIG_SERIALIZATION => array( @@ -33,6 +35,12 @@ 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( @@ -41,14 +49,46 @@ 'overwrite an existing chart configuration.')); } - $digest = serialize($this->chartParameters); - $digest = PhabricatorHash::digestForIndex($digest); - - $this->chartKey = $digest; + $this->chartKey = $this->newChartKey(); return parent::save(); } + public function setDatasets(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; + + 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; + } + + public function getURI() { + return urisprintf('/fact/chart/%s/', $this->getChartKey()); + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() {