diff --git a/resources/celerity/map.php b/resources/celerity/map.php index d1233b3684..73e7c19144 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -141,7 +141,7 @@ return array( 'rsrc/css/phui/phui-big-info-view.css' => '362ad37b', 'rsrc/css/phui/phui-box.css' => '5ed3b8cb', 'rsrc/css/phui/phui-bulk-editor.css' => '374d5e30', - 'rsrc/css/phui/phui-chart.css' => '10135a9d', + 'rsrc/css/phui/phui-chart.css' => '14df9ae3', 'rsrc/css/phui/phui-cms.css' => '8c05c41e', 'rsrc/css/phui/phui-comment-form.css' => '68a2d99a', 'rsrc/css/phui/phui-comment-panel.css' => 'ec4e31c0', @@ -390,7 +390,7 @@ return array( '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' => 'eec96de0', + 'rsrc/js/application/fact/Chart.js' => '52e3ff03', 'rsrc/js/application/fact/ChartCurtainView.js' => '86954222', 'rsrc/js/application/fact/ChartFunctionLabel.js' => '81de1dab', 'rsrc/js/application/files/behavior-document-engine.js' => '243d6c22', @@ -699,7 +699,7 @@ return array( 'javelin-behavior-user-menu' => '60cd9241', 'javelin-behavior-view-placeholder' => 'a9942052', 'javelin-behavior-workflow' => '9623adc1', - 'javelin-chart' => 'eec96de0', + 'javelin-chart' => '52e3ff03', 'javelin-chart-curtain-view' => '86954222', 'javelin-chart-function-label' => '81de1dab', 'javelin-color' => '78f811c9', @@ -828,7 +828,7 @@ return array( 'phui-calendar-day-css' => '9597d706', 'phui-calendar-list-css' => 'ccd7e4e2', 'phui-calendar-month-css' => 'cb758c42', - 'phui-chart-css' => '10135a9d', + 'phui-chart-css' => '14df9ae3', 'phui-cms-css' => '8c05c41e', 'phui-comment-form-css' => '68a2d99a', 'phui-comment-panel-css' => 'ec4e31c0', @@ -1369,6 +1369,12 @@ return array( 'javelin-dom', 'javelin-fx', ), + '52e3ff03' => array( + 'phui-chart-css', + 'd3', + 'javelin-chart-curtain-view', + 'javelin-chart-function-label', + ), '541f81c3' => array( 'javelin-install', ), @@ -2127,12 +2133,6 @@ return array( 'phabricator-keyboard-shortcut', 'javelin-stratcom', ), - 'eec96de0' => array( - 'phui-chart-css', - 'd3', - 'javelin-chart-curtain-view', - 'javelin-chart-function-label', - ), 'ef836bf2' => array( 'javelin-behavior', 'javelin-dom', diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 95c5059d06..f062b577c3 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -3104,6 +3104,7 @@ phutil_register_library_map(array( 'PhabricatorDefaultRequestExceptionHandler' => 'aphront/handler/PhabricatorDefaultRequestExceptionHandler.php', 'PhabricatorDefaultSyntaxStyle' => 'infrastructure/syntax/PhabricatorDefaultSyntaxStyle.php', 'PhabricatorDefaultUnlockEngine' => 'applications/system/engine/PhabricatorDefaultUnlockEngine.php', + 'PhabricatorDemoChartEngine' => 'applications/fact/engine/PhabricatorDemoChartEngine.php', 'PhabricatorDestructibleCodex' => 'applications/system/codex/PhabricatorDestructibleCodex.php', 'PhabricatorDestructibleCodexInterface' => 'applications/system/interface/PhabricatorDestructibleCodexInterface.php', 'PhabricatorDestructibleInterface' => 'applications/system/interface/PhabricatorDestructibleInterface.php', @@ -4223,6 +4224,7 @@ phutil_register_library_map(array( 'PhabricatorProfileMenuItemView' => 'applications/search/engine/PhabricatorProfileMenuItemView.php', 'PhabricatorProfileMenuItemViewList' => 'applications/search/engine/PhabricatorProfileMenuItemViewList.php', 'PhabricatorProject' => 'applications/project/storage/PhabricatorProject.php', + 'PhabricatorProjectActivityChartEngine' => 'applications/project/chart/PhabricatorProjectActivityChartEngine.php', 'PhabricatorProjectAddHeraldAction' => 'applications/project/herald/PhabricatorProjectAddHeraldAction.php', 'PhabricatorProjectApplication' => 'applications/project/application/PhabricatorProjectApplication.php', 'PhabricatorProjectArchiveController' => 'applications/project/controller/PhabricatorProjectArchiveController.php', @@ -4422,6 +4424,7 @@ phutil_register_library_map(array( 'PhabricatorProjectsWatchersSearchEngineAttachment' => 'applications/project/engineextension/PhabricatorProjectsWatchersSearchEngineAttachment.php', 'PhabricatorPronounSetting' => 'applications/settings/setting/PhabricatorPronounSetting.php', 'PhabricatorProtocolLog' => 'infrastructure/log/PhabricatorProtocolLog.php', + 'PhabricatorPureChartFunction' => 'applications/fact/chart/PhabricatorPureChartFunction.php', 'PhabricatorPygmentSetupCheck' => 'applications/config/check/PhabricatorPygmentSetupCheck.php', 'PhabricatorQuery' => 'infrastructure/query/PhabricatorQuery.php', 'PhabricatorQueryConstraint' => 'infrastructure/query/constraint/PhabricatorQueryConstraint.php', @@ -8300,7 +8303,7 @@ phutil_register_library_map(array( 'PhabricatorAccessLog' => 'Phobject', 'PhabricatorAccessLogConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorAccessibilitySetting' => 'PhabricatorSelectSetting', - 'PhabricatorAccumulateChartFunction' => 'PhabricatorChartFunction', + 'PhabricatorAccumulateChartFunction' => 'PhabricatorHigherOrderChartFunction', 'PhabricatorActionListView' => 'AphrontTagView', 'PhabricatorActionView' => 'AphrontView', 'PhabricatorActivitySettingsPanel' => 'PhabricatorSettingsPanel', @@ -9155,7 +9158,7 @@ phutil_register_library_map(array( 'PhabricatorConpherenceWidgetVisibleSetting' => 'PhabricatorInternalSetting', 'PhabricatorConsoleApplication' => 'PhabricatorApplication', 'PhabricatorConsoleContentSource' => 'PhabricatorContentSource', - 'PhabricatorConstantChartFunction' => 'PhabricatorChartFunction', + 'PhabricatorConstantChartFunction' => 'PhabricatorPureChartFunction', 'PhabricatorContactNumbersSettingsPanel' => 'PhabricatorSettingsPanel', 'PhabricatorContentSource' => 'Phobject', 'PhabricatorContentSourceModule' => 'PhabricatorConfigModule', @@ -9167,7 +9170,7 @@ phutil_register_library_map(array( 'PhabricatorCoreCreateTransaction' => 'PhabricatorCoreTransactionType', 'PhabricatorCoreTransactionType' => 'PhabricatorModularTransactionType', 'PhabricatorCoreVoidTransaction' => 'PhabricatorModularTransactionType', - 'PhabricatorCosChartFunction' => 'PhabricatorChartFunction', + 'PhabricatorCosChartFunction' => 'PhabricatorPureChartFunction', 'PhabricatorCountFact' => 'PhabricatorFact', 'PhabricatorCountdown' => array( 'PhabricatorCountdownDAO', @@ -9433,6 +9436,7 @@ phutil_register_library_map(array( 'PhabricatorDefaultRequestExceptionHandler' => 'PhabricatorRequestExceptionHandler', 'PhabricatorDefaultSyntaxStyle' => 'PhabricatorSyntaxStyle', 'PhabricatorDefaultUnlockEngine' => 'PhabricatorUnlockEngine', + 'PhabricatorDemoChartEngine' => 'PhabricatorChartEngine', 'PhabricatorDestructibleCodex' => 'Phobject', 'PhabricatorDestructionEngine' => 'Phobject', 'PhabricatorDestructionEngineExtension' => 'Phobject', @@ -10068,7 +10072,7 @@ phutil_register_library_map(array( 'PhabricatorMarkupInterface', ), 'PhabricatorMarkupPreviewController' => 'PhabricatorController', - 'PhabricatorMaxChartFunction' => 'PhabricatorChartFunction', + 'PhabricatorMaxChartFunction' => 'PhabricatorPureChartFunction', 'PhabricatorMemeEngine' => 'Phobject', 'PhabricatorMemeRemarkupRule' => 'PhutilRemarkupRule', 'PhabricatorMentionRemarkupRule' => 'PhutilRemarkupRule', @@ -10135,7 +10139,7 @@ phutil_register_library_map(array( 'PhabricatorMetronome' => 'Phobject', 'PhabricatorMetronomeTestCase' => 'PhabricatorTestCase', 'PhabricatorMetronomicTriggerClock' => 'PhabricatorTriggerClock', - 'PhabricatorMinChartFunction' => 'PhabricatorChartFunction', + 'PhabricatorMinChartFunction' => 'PhabricatorPureChartFunction', 'PhabricatorModularTransaction' => 'PhabricatorApplicationTransaction', 'PhabricatorModularTransactionType' => 'Phobject', 'PhabricatorMonogramDatasourceEngineExtension' => 'PhabricatorDatasourceEngineExtension', @@ -10730,6 +10734,7 @@ phutil_register_library_map(array( 'PhabricatorSpacesInterface', 'PhabricatorEditEngineSubtypeInterface', ), + 'PhabricatorProjectActivityChartEngine' => 'PhabricatorChartEngine', 'PhabricatorProjectAddHeraldAction' => 'PhabricatorProjectHeraldAction', 'PhabricatorProjectApplication' => 'PhabricatorApplication', 'PhabricatorProjectArchiveController' => 'PhabricatorProjectController', @@ -10950,6 +10955,7 @@ phutil_register_library_map(array( 'PhabricatorProjectsWatchersSearchEngineAttachment' => 'PhabricatorSearchEngineAttachment', 'PhabricatorPronounSetting' => 'PhabricatorSelectSetting', 'PhabricatorProtocolLog' => 'Phobject', + 'PhabricatorPureChartFunction' => 'PhabricatorChartFunction', 'PhabricatorPygmentSetupCheck' => 'PhabricatorSetupCheck', 'PhabricatorQuery' => 'Phobject', 'PhabricatorQueryConstraint' => 'Phobject', @@ -11208,7 +11214,7 @@ phutil_register_library_map(array( 'PhabricatorPolicyInterface', ), 'PhabricatorSavedQueryQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', - 'PhabricatorScaleChartFunction' => 'PhabricatorChartFunction', + 'PhabricatorScaleChartFunction' => 'PhabricatorPureChartFunction', 'PhabricatorScheduleTaskTriggerAction' => 'PhabricatorTriggerAction', 'PhabricatorScopedEnv' => 'Phobject', 'PhabricatorSearchAbstractDocument' => 'Phobject', @@ -11299,12 +11305,12 @@ phutil_register_library_map(array( 'PhabricatorSetupIssue' => 'Phobject', 'PhabricatorSetupIssueUIExample' => 'PhabricatorUIExample', 'PhabricatorSetupIssueView' => 'AphrontView', - 'PhabricatorShiftChartFunction' => 'PhabricatorChartFunction', + 'PhabricatorShiftChartFunction' => 'PhabricatorPureChartFunction', 'PhabricatorShortSite' => 'PhabricatorSite', 'PhabricatorShowFiletreeSetting' => 'PhabricatorSelectSetting', 'PhabricatorSignDocumentsUserLogType' => 'PhabricatorUserLogType', 'PhabricatorSimpleEditType' => 'PhabricatorEditType', - 'PhabricatorSinChartFunction' => 'PhabricatorChartFunction', + 'PhabricatorSinChartFunction' => 'PhabricatorPureChartFunction', 'PhabricatorSite' => 'AphrontSite', 'PhabricatorSlackAuthProvider' => 'PhabricatorOAuth2AuthProvider', 'PhabricatorSlowvoteApplication' => 'PhabricatorApplication', diff --git a/src/applications/fact/application/PhabricatorFactApplication.php b/src/applications/fact/application/PhabricatorFactApplication.php index b3e0417754..f32d6839e2 100644 --- a/src/applications/fact/application/PhabricatorFactApplication.php +++ b/src/applications/fact/application/PhabricatorFactApplication.php @@ -22,15 +22,10 @@ final class PhabricatorFactApplication extends PhabricatorApplication { return self::GROUP_UTILITIES; } - public function isPrototype() { - return true; - } - public function getRoutes() { return array( '/fact/' => array( '' => 'PhabricatorFactHomeController', - 'chart/' => 'PhabricatorFactChartController', 'chart/(?P[^/]+)/(?:(?Pdraw)/)?' => 'PhabricatorFactChartController', 'object/(?[^/]+)/' => 'PhabricatorFactObjectController', diff --git a/src/applications/fact/chart/PhabricatorAccumulateChartFunction.php b/src/applications/fact/chart/PhabricatorAccumulateChartFunction.php index 6ffbb85da9..63570ef234 100644 --- a/src/applications/fact/chart/PhabricatorAccumulateChartFunction.php +++ b/src/applications/fact/chart/PhabricatorAccumulateChartFunction.php @@ -1,7 +1,7 @@ getArgument('x')->getDomain(); - } - - public function newInputValues(PhabricatorChartDataQuery $query) { - return $this->getArgument('x')->newInputValues($query); - } - public function evaluateFunction(array $xv) { // First, we're going to accumulate the underlying function. Then // we'll map the inputs through the accumulation. diff --git a/src/applications/fact/chart/PhabricatorChartDataset.php b/src/applications/fact/chart/PhabricatorChartDataset.php index 9faf02b740..093a742077 100644 --- a/src/applications/fact/chart/PhabricatorChartDataset.php +++ b/src/applications/fact/chart/PhabricatorChartDataset.php @@ -59,13 +59,6 @@ abstract class PhabricatorChartDataset 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); @@ -75,4 +68,35 @@ abstract class PhabricatorChartDataset 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 ac7ab64650..3ddcd6aec0 100644 --- a/src/applications/fact/chart/PhabricatorChartFunction.php +++ b/src/applications/fact/chart/PhabricatorChartFunction.php @@ -60,6 +60,10 @@ abstract class PhabricatorChartFunction return $this->functionLabel; } + final public function getKey() { + return $this->getFunctionLabel()->getKey(); + } + final public static function newFromDictionary(array $map) { PhutilTypeSpec::checkMap( $map, @@ -86,13 +90,6 @@ abstract class PhabricatorChartFunction return $function; } - public function toDictionary() { - return array( - 'function' => $this->getFunctionKey(), - 'arguments' => $this->getArgumentParser()->getRawArguments(), - ); - } - public function getSubfunctions() { $result = array(); $result[] = $this; @@ -180,6 +177,8 @@ abstract class PhabricatorChartFunction } abstract public function evaluateFunction(array $xv); + abstract public function getDataRefs(array $xv); + abstract public function loadRefs(array $refs); public function getDomain() { return null; 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 @@ -3,11 +3,21 @@ final class PhabricatorChartFunctionLabel extends Phobject { + private $key; private $name; private $color; private $icon; private $fillColor; + public function setKey($key) { + $this->key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + public function setName($name) { $this->name = $name; return $this; @@ -46,6 +56,7 @@ final class PhabricatorChartFunctionLabel public function toWireFormat() { return array( + 'key' => $this->getKey(), 'name' => $this->getName(), 'color' => $this->getColor(), 'icon' => $this->getIcon(), diff --git a/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php b/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php index 8bf4445984..2ba08ea1c9 100644 --- a/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php +++ b/src/applications/fact/chart/PhabricatorChartStackedAreaDataset.php @@ -5,24 +5,187 @@ final class PhabricatorChartStackedAreaDataset const DATASETKEY = 'stacked-area'; + private $stacks; + + public function setStacks(array $stacks) { + $this->stacks = $stacks; + return $this; + } + + public function getStacks() { + return $this->stacks; + } + protected function newChartDisplayData( PhabricatorChartDataQuery $data_query) { + $functions = $this->getFunctions(); + $functions = mpull($functions, null, 'getKey'); - $reversed_functions = array_reverse($functions, true); + $stacks = $this->getStacks(); - $function_points = array(); - foreach ($reversed_functions as $function_idx => $function) { - $function_points[$function_idx] = array(); + if (!$stacks) { + $stacks = array( + array_reverse(array_keys($functions), true), + ); + } - $datapoints = $function->newDatapoints($data_query); - foreach ($datapoints as $point) { - $x = $point['x']; - $function_points[$function_idx][$x] = $point; + $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; + } + } + + $series[$function_idx] = $bounds; + } + + $raw_points += $stack_points; + } + + $series = array_select_keys($series, array_keys($functions)); + $series = array_values($series); + + $raw_points = array_select_keys($raw_points, array_keys($functions)); + $raw_points = array_values($raw_points); + + $range_min = null; + $range_max = null; + + 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); } } - $raw_points = $function_points; + // 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 @@ -31,17 +194,54 @@ final class PhabricatorChartStackedAreaDataset // stacking the functions on top of one another. $must_define = array(); - foreach ($function_points as $function_idx => $points) { - foreach ($points as $x => $point) { + + $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); - foreach ($reversed_functions as $function_idx => $function) { + 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($function_points[$function_idx][$x])) { + if (!isset($points[$x])) { $missing[$x] = true; } } @@ -50,8 +250,6 @@ final class PhabricatorChartStackedAreaDataset continue; } - $points = $function_points[$function_idx]; - $values = array_keys($points); $cursor = -1; $length = count($values); @@ -84,88 +282,19 @@ final class PhabricatorChartStackedAreaDataset $y = $ymin + (($ymax - $ymin) * $distance); } else { $xmin = $values[$cursor]; - $y = $function_points[$function_idx][$xmin]['y']; + $y = $points[$xmin]['y']; } - $function_points[$function_idx][$x] = array( + $point_lists[$idx][$x] = array( 'x' => $x, 'y' => $y, ); } - ksort($function_points[$function_idx]); + ksort($point_lists[$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; - } - - $series = array_reverse($series); - - $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(); - $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)); + return $point_lists; } - } diff --git a/src/applications/fact/chart/PhabricatorComposeChartFunction.php b/src/applications/fact/chart/PhabricatorComposeChartFunction.php index f6148ceae9..e69455b3ff 100644 --- a/src/applications/fact/chart/PhabricatorComposeChartFunction.php +++ b/src/applications/fact/chart/PhabricatorComposeChartFunction.php @@ -70,4 +70,22 @@ final class PhabricatorComposeChartFunction return $yv; } + public function getDataRefs(array $xv) { + // TODO: This is not entirely correct. The correct implementation would + // map "x -> y" at each stage of composition and pull and aggregate all + // the datapoint refs. In practice, we currently never compose functions + // with a data function somewhere in the middle, so just grabbing the first + // result is close enough. + + // In the future, we may: notably, "x -> shift(-1 month) -> ..." to + // generate a month-over-month overlay is a sensible operation which will + // source data from the middle of a function composition. + + foreach ($this->getFunctionArguments() as $function) { + return $function->getDataRefs($xv); + } + + return array(); + } + } diff --git a/src/applications/fact/chart/PhabricatorConstantChartFunction.php b/src/applications/fact/chart/PhabricatorConstantChartFunction.php index cdc6c9494a..e65c577827 100644 --- a/src/applications/fact/chart/PhabricatorConstantChartFunction.php +++ b/src/applications/fact/chart/PhabricatorConstantChartFunction.php @@ -1,7 +1,7 @@ newArgument() @@ -51,13 +52,15 @@ final class PhabricatorFactChartFunction $data = queryfx_all( $conn, - 'SELECT value, epoch FROM %T WHERE %LA ORDER BY epoch ASC', + 'SELECT id, value, epoch FROM %T WHERE %LA ORDER BY epoch ASC', $table_name, $where); $map = array(); + $refs = array(); if ($data) { foreach ($data as $row) { + $ref = (string)$row['id']; $value = (int)$row['value']; $epoch = (int)$row['epoch']; @@ -66,10 +69,17 @@ final class PhabricatorFactChartFunction } $map[$epoch] += $value; + + if (!isset($refs[$epoch])) { + $refs[$epoch] = array(); + } + + $refs[$epoch][] = $ref; } } $this->map = $map; + $this->refs = $refs; } public function getDomain() { @@ -99,4 +109,60 @@ final class PhabricatorFactChartFunction return $yv; } + public function getDataRefs(array $xv) { + return array_select_keys($this->refs, $xv); + } + + public function loadRefs(array $refs) { + $fact = $this->fact; + + $datapoint_table = $fact->newDatapoint(); + $conn = $datapoint_table->establishConnection('r'); + + $dimension_table = new PhabricatorFactObjectDimension(); + + $where = array(); + + $where[] = qsprintf( + $conn, + 'p.id IN (%Ld)', + $refs); + + + $rows = queryfx_all( + $conn, + 'SELECT + p.id id, + p.value, + od.objectPHID objectPHID, + dd.objectPHID dimensionPHID + FROM %R p + LEFT JOIN %R od ON od.id = p.objectID + LEFT JOIN %R dd ON dd.id = p.dimensionID + WHERE %LA', + $datapoint_table, + $dimension_table, + $dimension_table, + $where); + $rows = ipull($rows, null, 'id'); + + $results = array(); + + foreach ($refs as $ref) { + if (!isset($rows[$ref])) { + continue; + } + + $row = $rows[$ref]; + + $results[$ref] = array( + 'objectPHID' => $row['objectPHID'], + 'dimensionPHID' => $row['dimensionPHID'], + 'value' => (float)$row['value'], + ); + } + + return $results; + } + } diff --git a/src/applications/fact/chart/PhabricatorHigherOrderChartFunction.php b/src/applications/fact/chart/PhabricatorHigherOrderChartFunction.php index ab160bd10f..7124603388 100644 --- a/src/applications/fact/chart/PhabricatorHigherOrderChartFunction.php +++ b/src/applications/fact/chart/PhabricatorHigherOrderChartFunction.php @@ -32,4 +32,38 @@ abstract class PhabricatorHigherOrderChartFunction return array_keys($map); } + public function getDataRefs(array $xv) { + $refs = array(); + + foreach ($this->getFunctionArguments() as $function) { + $function_refs = $function->getDataRefs($xv); + + $function_refs = array_select_keys($function_refs, $xv); + if (!$function_refs) { + continue; + } + + foreach ($function_refs as $x => $ref_list) { + if (!isset($refs[$x])) { + $refs[$x] = array(); + } + foreach ($ref_list as $ref) { + $refs[$x][] = $ref; + } + } + } + + return $refs; + } + + public function loadRefs(array $refs) { + $results = array(); + + foreach ($this->getFunctionArguments() as $function) { + $results += $function->loadRefs($refs); + } + + return $results; + } + } diff --git a/src/applications/fact/chart/PhabricatorMaxChartFunction.php b/src/applications/fact/chart/PhabricatorMaxChartFunction.php index c874cef8e8..accf217328 100644 --- a/src/applications/fact/chart/PhabricatorMaxChartFunction.php +++ b/src/applications/fact/chart/PhabricatorMaxChartFunction.php @@ -1,36 +1,27 @@ newArgument() - ->setName('x') - ->setType('function'), $this->newArgument() ->setName('max') ->setType('number'), ); } - public function getDomain() { - return $this->getArgument('x')->getDomain(); - } - - public function newInputValues(PhabricatorChartDataQuery $query) { - return $this->getArgument('x')->newInputValues($query); - } - public function evaluateFunction(array $xv) { - $yv = $this->getArgument('x')->evaluateFunction($xv); $max = $this->getArgument('max'); - foreach ($yv as $k => $y) { - if ($y > $max) { - $yv[$k] = null; + $yv = array(); + foreach ($xv as $x) { + if ($x > $max) { + $yv[] = null; + } else { + $yv[] = $x; } } diff --git a/src/applications/fact/chart/PhabricatorMinChartFunction.php b/src/applications/fact/chart/PhabricatorMinChartFunction.php index db1a003811..e6dcec06a4 100644 --- a/src/applications/fact/chart/PhabricatorMinChartFunction.php +++ b/src/applications/fact/chart/PhabricatorMinChartFunction.php @@ -1,36 +1,27 @@ newArgument() - ->setName('x') - ->setType('function'), $this->newArgument() ->setName('min') ->setType('number'), ); } - public function getDomain() { - return $this->getArgument('x')->getDomain(); - } - - public function newInputValues(PhabricatorChartDataQuery $query) { - return $this->getArgument('x')->newInputValues($query); - } - public function evaluateFunction(array $xv) { - $yv = $this->getArgument('x')->evaluateFunction($xv); $min = $this->getArgument('min'); - foreach ($yv as $k => $y) { - if ($y < $min) { - $yv[$k] = null; + $yv = array(); + foreach ($xv as $x) { + if ($x < $min) { + $yv[] = null; + } else { + $yv[] = $x; } } diff --git a/src/applications/fact/chart/PhabricatorPureChartFunction.php b/src/applications/fact/chart/PhabricatorPureChartFunction.php new file mode 100644 index 0000000000..74c748c274 --- /dev/null +++ b/src/applications/fact/chart/PhabricatorPureChartFunction.php @@ -0,0 +1,14 @@ +getViewer(); $chart_key = $request->getURIData('chartKey'); - if ($chart_key === null) { - return $this->newDemoChart(); + if (!$chart_key) { + return new Aphront404Response(); } $engine = id(new PhabricatorChartRenderingEngine()) @@ -24,15 +25,24 @@ final class PhabricatorFactChartController extends PhabricatorFactController { $mode = $request->getURIData('mode'); $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(); + $want_data = $is_draw_mode; - if ($is_draw_mode) { - return id(new AphrontAjaxResponse())->setContent($chart_data); + // In developer mode, always pull the data in the main request. We'll + // throw it away if we're just drawing the chart frame, but this currently + // makes errors quite a bit easier to debug. + if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) { + $want_data = true; + } + + if ($want_data) { + $chart_data = $engine->newChartData(); + if ($is_draw_mode) { + return id(new AphrontAjaxResponse())->setContent($chart_data); + } } $chart_view = $engine->newChartView(); + return $this->newChartResponse($chart_view); } @@ -50,58 +60,10 @@ final class PhabricatorFactChartController extends PhabricatorFactController { return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) - ->appendChild($box); - } - - private function newDemoChart() { - $viewer = $this->getViewer(); - - $argvs = array(); - - $argvs[] = array('fact', 'tasks.count.create'); - - $argvs[] = array('constant', 360); - - $argvs[] = array('fact', 'tasks.open-count.create'); - - $argvs[] = array( - 'sum', - array( - 'accumulate', - array('fact', 'tasks.count.create'), - ), - array( - 'accumulate', - array('fact', 'tasks.open-count.create'), - ), - ); - - $argvs[] = array( - 'compose', - array('scale', 0.001), - array('cos'), - array('scale', 100), - array('shift', 800), - ); - - $datasets = array(); - foreach ($argvs as $argv) { - $datasets[] = PhabricatorChartDataset::newFromDictionary( + ->appendChild( array( - 'function' => $argv, + $box, )); - } - - $chart = id(new PhabricatorFactChart()) - ->setDatasets($datasets); - - $engine = id(new PhabricatorChartRenderingEngine()) - ->setViewer($viewer) - ->setChart($chart); - - $chart = $engine->getStoredChart(); - - return id(new AphrontRedirectResponse())->setURI($chart->getURI()); } } diff --git a/src/applications/fact/controller/PhabricatorFactHomeController.php b/src/applications/fact/controller/PhabricatorFactHomeController.php index 56ffe3930b..666835db13 100644 --- a/src/applications/fact/controller/PhabricatorFactHomeController.php +++ b/src/applications/fact/controller/PhabricatorFactHomeController.php @@ -1,59 +1,20 @@ getViewer(); + $viewer = $this->getViewer(); - if ($request->isFormPost()) { - $uri = new PhutilURI('/fact/chart/'); - $uri->replaceQueryParam('y1', $request->getStr('y1')); - return id(new AphrontRedirectResponse())->setURI($uri); - } + $chart = id(new PhabricatorDemoChartEngine()) + ->setViewer($viewer) + ->newStoredChart(); - $chart_form = $this->buildChartForm(); - - $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Home')); - - $title = pht('Facts'); - - return $this->newPage() - ->setTitle($title) - ->setCrumbs($crumbs) - ->appendChild( - array( - $chart_form, - )); - } - - private function buildChartForm() { - $request = $this->getRequest(); - $viewer = $request->getUser(); - - $specs = PhabricatorFact::getAllFacts(); - $options = mpull($specs, 'getName', 'getKey'); - - $form = id(new AphrontFormView()) - ->setUser($viewer) - ->appendChild( - id(new AphrontFormSelectControl()) - ->setLabel(pht('Y-Axis')) - ->setName('y1') - ->setOptions($options)) - ->appendChild( - id(new AphrontFormSubmitControl()) - ->setValue(pht('Plot Chart'))); - - $panel = new PHUIObjectBoxView(); - $panel->setForm($form); - $panel->setHeaderText(pht('Plot Chart')); - - return $panel; + return id(new AphrontRedirectResponse())->setURI($chart->getURI()); } } diff --git a/src/applications/fact/engine/PhabricatorChartEngine.php b/src/applications/fact/engine/PhabricatorChartEngine.php index f723633d6a..918817d475 100644 --- a/src/applications/fact/engine/PhabricatorChartEngine.php +++ b/src/applications/fact/engine/PhabricatorChartEngine.php @@ -63,7 +63,7 @@ abstract class PhabricatorChartEngine abstract protected function newChart(PhabricatorFactChart $chart, array $map); - final public function buildChartPanel() { + final public function newStoredChart() { $viewer = $this->getViewer(); $parameters = $this->getEngineParameters(); @@ -76,7 +76,11 @@ abstract class PhabricatorChartEngine ->setViewer($viewer) ->setChart($chart); - $chart = $rendering_engine->getStoredChart(); + return $rendering_engine->getStoredChart(); + } + + final public function buildChartPanel() { + $chart = $this->newStoredChart(); $panel_type = id(new PhabricatorDashboardChartPanelType()) ->getPanelTypeKey(); @@ -91,7 +95,7 @@ abstract class PhabricatorChartEngine final protected function newFunction($name /* , ... */) { $argv = func_get_args(); return id(new PhabricatorComposeChartFunction()) - ->setArguments(array($argv)); + ->setArguments($argv); } } diff --git a/src/applications/fact/engine/PhabricatorChartRenderingEngine.php b/src/applications/fact/engine/PhabricatorChartRenderingEngine.php index b328241ea6..bf487f0052 100644 --- a/src/applications/fact/engine/PhabricatorChartRenderingEngine.php +++ b/src/applications/fact/engine/PhabricatorChartRenderingEngine.php @@ -109,7 +109,146 @@ final class PhabricatorChartRenderingEngine return $chart_view; } + public function newTabularView() { + $viewer = $this->getViewer(); + $tabular_data = $this->newTabularData(); + + $ref_keys = array(); + foreach ($tabular_data['datasets'] as $tabular_dataset) { + foreach ($tabular_dataset as $function) { + foreach ($function['data'] as $point) { + foreach ($point['refs'] as $ref) { + $ref_keys[$ref] = $ref; + } + } + } + } + + $chart = $this->getStoredChart(); + + $ref_map = array(); + foreach ($chart->getDatasets() as $dataset) { + foreach ($dataset->getFunctions() as $function) { + // If we aren't looking for anything else, bail out. + if (!$ref_keys) { + break 2; + } + + $function_refs = $function->loadRefs($ref_keys); + + $ref_map += $function_refs; + + // Remove the ref keys that we found data for from the list of keys + // we are looking for. If any function gives us data for a given ref, + // that's satisfactory. + foreach ($function_refs as $ref_key => $ref_data) { + unset($ref_keys[$ref_key]); + } + } + } + + $phids = array(); + foreach ($ref_map as $ref => $ref_data) { + if (isset($ref_data['objectPHID'])) { + $phids[] = $ref_data['objectPHID']; + } + } + + $handles = $viewer->loadHandles($phids); + + $tabular_view = array(); + foreach ($tabular_data['datasets'] as $tabular_data) { + foreach ($tabular_data as $function) { + $rows = array(); + foreach ($function['data'] as $point) { + $ref_views = array(); + + $xv = date('Y-m-d h:i:s', $point['x']); + $yv = $point['y']; + + $point_refs = array(); + foreach ($point['refs'] as $ref) { + if (!isset($ref_map[$ref])) { + continue; + } + $point_refs[$ref] = $ref_map[$ref]; + } + + if (!$point_refs) { + $rows[] = array( + $xv, + $yv, + null, + null, + null, + ); + } else { + foreach ($point_refs as $ref => $ref_data) { + $ref_value = $ref_data['value']; + $ref_link = $handles[$ref_data['objectPHID']] + ->renderLink(); + + $view_uri = urisprintf( + '/fact/object/%s/', + $ref_data['objectPHID']); + + $ref_button = id(new PHUIButtonView()) + ->setIcon('fa-table') + ->setTag('a') + ->setColor('grey') + ->setHref($view_uri) + ->setText(pht('View Data')); + + $rows[] = array( + $xv, + $yv, + $ref_value, + $ref_link, + $ref_button, + ); + + $xv = null; + $yv = null; + } + } + } + + $table = id(new AphrontTableView($rows)) + ->setHeaders( + array( + pht('X'), + pht('Y'), + pht('Raw'), + pht('Refs'), + null, + )) + ->setColumnClasses( + array( + 'n', + 'n', + 'n', + 'wide', + null, + )); + + $tabular_view[] = id(new PHUIObjectBoxView()) + ->setHeaderText(pht('Function')) + ->setTable($table); + } + } + + return $tabular_view; + } + public function newChartData() { + return $this->newWireData(false); + } + + public function newTabularData() { + return $this->newWireData(true); + } + + private function newWireData($is_tabular) { $chart = $this->getStoredChart(); $chart_key = $chart->getChartKey(); @@ -151,7 +290,11 @@ final class PhabricatorChartRenderingEngine $wire_datasets = array(); $ranges = array(); foreach ($datasets as $dataset) { - $display_data = $dataset->getChartDisplayData($data_query); + if ($is_tabular) { + $display_data = $dataset->getTabularDisplayData($data_query); + } else { + $display_data = $dataset->getChartDisplayData($data_query); + } $ranges[] = $display_data->getRange(); $wire_datasets[] = $display_data->getWireData(); diff --git a/src/applications/fact/engine/PhabricatorDemoChartEngine.php b/src/applications/fact/engine/PhabricatorDemoChartEngine.php new file mode 100644 index 0000000000..218d30473d --- /dev/null +++ b/src/applications/fact/engine/PhabricatorDemoChartEngine.php @@ -0,0 +1,46 @@ +getViewer(); + + $functions = array(); + + $function = $this->newFunction( + array('scale', 0.0001), + array('cos'), + array('scale', 128), + array('shift', 256)); + + $function->getFunctionLabel() + ->setKey('cos-x') + ->setName(pht('cos(x)')) + ->setColor('rgba(0, 200, 0, 1)') + ->setFillColor('rgba(0, 200, 0, 0.15)'); + + $functions[] = $function; + + $function = $this->newFunction( + array('constant', 345)); + + $function->getFunctionLabel() + ->setKey('constant-345') + ->setName(pht('constant(345)')) + ->setColor('rgba(0, 0, 200, 1)') + ->setFillColor('rgba(0, 0, 200, 0.15)'); + + $functions[] = $function; + + $datasets = array(); + + $datasets[] = id(new PhabricatorChartStackedAreaDataset()) + ->setFunctions($functions); + + $chart->attachDatasets($datasets); + } + +} diff --git a/src/applications/maniphest/controller/ManiphestReportController.php b/src/applications/maniphest/controller/ManiphestReportController.php index a5141e6122..f1940dfd80 100644 --- a/src/applications/maniphest/controller/ManiphestReportController.php +++ b/src/applications/maniphest/controller/ManiphestReportController.php @@ -35,8 +35,12 @@ final class ManiphestReportController extends ManiphestController { $nav->addLabel(pht('Open Tasks')); $nav->addFilter('user', pht('By User')); $nav->addFilter('project', pht('By Project')); - $nav->addLabel(pht('Burnup')); - $nav->addFilter('burn', pht('Burnup Rate')); + + $class = 'PhabricatorFactApplication'; + if (PhabricatorApplication::isClassInstalledForViewer($class, $viewer)) { + $nav->addLabel(pht('Burnup')); + $nav->addFilter('burn', pht('Burnup Rate')); + } $this->view = $nav->selectFilter($this->view, 'user'); diff --git a/src/applications/project/chart/PhabricatorProjectActivityChartEngine.php b/src/applications/project/chart/PhabricatorProjectActivityChartEngine.php new file mode 100644 index 0000000000..7fc599317f --- /dev/null +++ b/src/applications/project/chart/PhabricatorProjectActivityChartEngine.php @@ -0,0 +1,135 @@ +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(); + } + + $project_phid = head($project_phids); + + $functions = array(); + $stacks = array(); + + $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.status.project', $project_phid), + array('max', 0), + ), + )); + + $function->getFunctionLabel() + ->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( + 'compose', + array('fact', 'tasks.open-count.assign.project', $project_phid), + array('max', 0), + ), + )); + + $function->getFunctionLabel() + ->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'); + + $datasets = array(); + + $dataset = id(new PhabricatorChartStackedAreaDataset()) + ->setFunctions($functions) + ->setStacks($stacks); + + $datasets[] = $dataset; + $chart->attachDatasets($datasets); + } + +} diff --git a/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php b/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php index 092e921c5a..1296f2eec8 100644 --- a/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php +++ b/src/applications/project/chart/PhabricatorProjectBurndownChartEngine.php @@ -30,97 +30,78 @@ final class PhabricatorProjectBurndownChartEngine $functions = array(); if ($project_phids) { - foreach ($project_phids as $project_phid) { - $function = $this->newFunction( - 'min', + $open_function = $this->newFunction( + array( + 'accumulate', array( - 'accumulate', - array('fact', 'tasks.open-count.assign.project', $project_phid), + 'sum', + $this->newFactSum( + 'tasks.open-count.create.project', $project_phids), + $this->newFactSum( + 'tasks.open-count.status.project', $project_phids), + $this->newFactSum( + 'tasks.open-count.assign.project', $project_phids), ), - 0); + )); - $function->getFunctionLabel() - ->setName(pht('Tasks Moved Into Project')) - ->setColor('rgba(0, 200, 200, 1)') - ->setFillColor('rgba(0, 200, 200, 0.15)'); - - $functions[] = $function; - - $function = $this->newFunction( - 'min', - array( - 'accumulate', - array('fact', 'tasks.open-count.status.project', $project_phid), - ), - 0); - - $function->getFunctionLabel() - ->setName(pht('Tasks Reopened')) - ->setColor('rgba(200, 0, 200, 1)') - ->setFillColor('rgba(200, 0, 200, 0.15)'); - - $functions[] = $function; - - $function = $this->newFunction( - 'sum', - array( - 'accumulate', - array('fact', 'tasks.open-count.create.project', $project_phid), - ), - array( - 'max', - array( - 'accumulate', - array('fact', 'tasks.open-count.status.project', $project_phid), - ), - 0, - ), - array( - 'max', - array( - 'accumulate', - array('fact', 'tasks.open-count.assign.project', $project_phid), - ), - 0, - )); - - $function->getFunctionLabel() - ->setName(pht('Tasks Created')) - ->setColor('rgba(0, 0, 200, 1)') - ->setFillColor('rgba(0, 0, 200, 0.15)'); - - $functions[] = $function; - } + $closed_function = $this->newFunction( + array( + 'accumulate', + $this->newFactSum('tasks.open-count.status.project', $project_phids), + )); } else { - $function = $this->newFunction( - 'accumulate', - array('fact', 'tasks.open-count.create')); + $open_function = $this->newFunction( + array( + 'accumulate', + array('fact', 'tasks.open-count.create'), + )); - $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; + $closed_function = $this->newFunction( + array( + 'accumulate', + array('fact', 'tasks.open-count.status'), + )); } + $open_function->getFunctionLabel() + ->setKey('open') + ->setName(pht('Open Tasks')) + ->setColor('rgba(0, 0, 200, 1)') + ->setFillColor('rgba(0, 0, 200, 0.15)'); + + $closed_function->getFunctionLabel() + ->setKey('closed') + ->setName(pht('Closed Tasks')) + ->setColor('rgba(0, 200, 0, 1)') + ->setFillColor('rgba(0, 200, 0, 0.15)'); + $datasets = array(); - $datasets[] = id(new PhabricatorChartStackedAreaDataset()) - ->setFunctions($functions); + $dataset = id(new PhabricatorChartStackedAreaDataset()) + ->setFunctions( + array( + $open_function, + $closed_function, + )) + ->setStacks( + array( + array('open'), + array('closed'), + )); + $datasets[] = $dataset; $chart->attachDatasets($datasets); } + private function newFactSum($fact_key, array $phids) { + $result = array(); + $result[] = 'sum'; + + foreach ($phids as $phid) { + $result[] = array('fact', $fact_key, $phid); + } + + return $result; + } + } diff --git a/src/applications/project/controller/PhabricatorProjectReportsController.php b/src/applications/project/controller/PhabricatorProjectReportsController.php index bee114917b..cfd9ee3253 100644 --- a/src/applications/project/controller/PhabricatorProjectReportsController.php +++ b/src/applications/project/controller/PhabricatorProjectReportsController.php @@ -44,10 +44,24 @@ final class PhabricatorProjectReportsController ->setParentPanelPHIDs(array()) ->renderPanel(); + $activity_panel = id(new PhabricatorProjectActivityChartEngine()) + ->setViewer($viewer) + ->setProjects(array($project)) + ->buildChartPanel(); + + $activity_panel->setName(pht('%s: Activity', $project->getName())); + + $activity_view = id(new PhabricatorDashboardPanelRenderingEngine()) + ->setViewer($viewer) + ->setPanel($activity_panel) + ->setParentPanelPHIDs(array()) + ->renderPanel(); + $view = id(new PHUITwoColumnView()) ->setFooter( array( $chart_view, + $activity_view, )); return $this->newPage() diff --git a/src/applications/project/menuitem/PhabricatorProjectReportsProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectReportsProfileMenuItem.php index d1350238f5..8639b6d72c 100644 --- a/src/applications/project/menuitem/PhabricatorProjectReportsProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectReportsProfileMenuItem.php @@ -34,6 +34,11 @@ final class PhabricatorProjectReportsProfileMenuItem return false; } + $class = 'PhabricatorFactApplication'; + if (!PhabricatorApplication::isClassInstalledForViewer($class, $viewer)) { + return false; + } + return true; } diff --git a/src/docs/user/configuration/configuring_preamble.diviner b/src/docs/user/configuration/configuring_preamble.diviner index 5299afa27d..6b6b9da149 100644 --- a/src/docs/user/configuration/configuring_preamble.diviner +++ b/src/docs/user/configuration/configuring_preamble.diviner @@ -82,7 +82,7 @@ if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { $real_address = your_custom_parsing_function($raw_header); - $_SERVER['REMOTE_ADDR'] = $raw_header; + $_SERVER['REMOTE_ADDR'] = $real_address; } ``` diff --git a/webroot/rsrc/css/phui/phui-chart.css b/webroot/rsrc/css/phui/phui-chart.css index 350d86014a..646ed52581 100644 --- a/webroot/rsrc/css/phui/phui-chart.css +++ b/webroot/rsrc/css/phui/phui-chart.css @@ -36,16 +36,17 @@ } .chart .point { - fill: {$lightblue}; + fill: #ffffff; stroke: {$blue}; - stroke-width: 1px; + stroke-width: 2px; + position: relative; + cursor: pointer; } .chart-tooltip { position: absolute; text-align: center; width: 120px; - height: 16px; overflow: hidden; padding: 2px; background: {$lightbluebackground}; diff --git a/webroot/rsrc/js/application/fact/Chart.js b/webroot/rsrc/js/application/fact/Chart.js index 9ce50822ee..473feedcea 100644 --- a/webroot/rsrc/js/application/fact/Chart.js +++ b/webroot/rsrc/js/application/fact/Chart.js @@ -133,18 +133,33 @@ JX.install('Chart', { }, _newStackedArea: function(g, dataset, x, y, div, curtain) { + var ii; + 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); }) + .y0(function(d) { + // When the area is positive, draw it above the X axis. When the area + // is negative, draw it below the X axis. We currently avoid having + // functions which cross the X axis by clever construction. + if (d.y0 >= 0 && d.y1 >= 0) { + return y(d.y0); + } + + if (d.y0 <= 0 && d.y1 <= 0) { + return y(d.y0); + } + + return y(0); + }) .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++) { + for (ii = 0; ii < dataset.data.length; ii++) { var label = new JX.ChartFunctionLabel(dataset.labels[ii]); var fill_color = label.getFillColor() || label.getColor(); @@ -160,6 +175,11 @@ JX.install('Chart', { .style('stroke', stroke_color) .attr('d', line(dataset.data[ii])); + curtain.addFunctionLabel(label); + } + + // Now that we've drawn all the areas and lines, draw the dots. + for (ii = 0; ii < dataset.data.length; ii++) { g.selectAll('dot') .data(dataset.events[ii]) .enter() @@ -178,8 +198,16 @@ JX.install('Chart', { var d_d = dd.getDate(); + var y = parseInt(d.y1); + + var label = d.n + ' Points'; + + var view = + d_y + '-' + d_m + '-' + d_d + ': ' + y + '
' + + label; + div - .html(d_y + '-' + d_m + '-' + d_d + ': ' + d.y1) + .html(view) .style('opacity', 0.9) .style('left', (d3.event.pageX - 60) + 'px') .style('top', (d3.event.pageY - 38) + 'px'); @@ -187,9 +215,8 @@ JX.install('Chart', { .on('mouseout', function() { div.style('opacity', 0); }); - - curtain.addFunctionLabel(label); } + }, _newDate: function(epoch) {