@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3final class PhabricatorChartStackedAreaDataset
4 extends PhabricatorChartDataset {
5
6 const DATASETKEY = 'stacked-area';
7
8 private $stacks;
9
10 /**
11 * @param array<array> $stacks One or more arrays with
12 * PhabricatorChartFunctionLabel names of each PhabricatorChartFunction,
13 * grouped by stacking
14 * (e.g. [["created","reopened","moved-in"],["closed","moved-out"]])
15 */
16 public function setStacks(array $stacks) {
17 $this->stacks = $stacks;
18 return $this;
19 }
20
21 public function getStacks() {
22 return $this->stacks;
23 }
24
25 /**
26 * @param PhabricatorChartDataQuery $data_query
27 * @return PhabricatorChartDisplayData
28 */
29 protected function newChartDisplayData(
30 PhabricatorChartDataQuery $data_query) {
31
32 $functions = $this->getFunctions();
33 $functions = mpull($functions, null, 'getKey');
34
35 $stacks = $this->getStacks();
36
37 if (!$stacks) {
38 $stacks = array(
39 array_reverse(array_keys($functions), true),
40 );
41 }
42
43 $series = array();
44 $raw_points = array();
45
46 foreach ($stacks as $stack) {
47 $stack_functions = array_select_keys($functions, $stack);
48
49 $function_points = $this->getFunctionDatapoints(
50 $data_query,
51 $stack_functions);
52
53 $stack_points = $function_points;
54
55 $function_points = $this->getGeometry(
56 $data_query,
57 $function_points);
58
59 $baseline = array();
60 foreach ($function_points as $function_idx => $points) {
61 $bounds = array();
62 foreach ($points as $x => $point) {
63 if (!isset($baseline[$x])) {
64 $baseline[$x] = 0;
65 }
66
67 $y0 = $baseline[$x];
68 $baseline[$x] += $point['y'];
69 $y1 = $baseline[$x];
70
71 $bounds[] = array(
72 'x' => $x,
73 'y0' => $y0,
74 'y1' => $y1,
75 );
76
77 if (isset($stack_points[$function_idx][$x])) {
78 $stack_points[$function_idx][$x]['y1'] = $y1;
79 }
80 }
81
82 $series[$function_idx] = $bounds;
83 }
84
85 $raw_points += $stack_points;
86 }
87
88 $series = array_select_keys($series, array_keys($functions));
89 $series = array_values($series);
90
91 $raw_points = array_select_keys($raw_points, array_keys($functions));
92 $raw_points = array_values($raw_points);
93
94 $range_min = null;
95 $range_max = null;
96
97 foreach ($series as $geometry_list) {
98 foreach ($geometry_list as $geometry_item) {
99 $y0 = $geometry_item['y0'];
100 $y1 = $geometry_item['y1'];
101
102 if ($range_min === null) {
103 $range_min = $y0;
104 }
105 $range_min = min($range_min, $y0, $y1);
106
107 if ($range_max === null) {
108 $range_max = $y1;
109 }
110 $range_max = max($range_max, $y0, $y1);
111 }
112 }
113
114 // We're going to group multiple events into a single point if they have
115 // X values that are very close to one another.
116 //
117 // If the Y values are also close to one another (these points are near
118 // one another in a horizontal line), it can be hard to select any
119 // individual point with the mouse.
120 //
121 // Even if the Y values are not close together (the points are on a
122 // fairly steep slope up or down), it's usually better to be able to
123 // mouse over a single point at the top or bottom of the slope and get
124 // a summary of what's going on.
125
126 $domain_max = $data_query->getMaximumValue();
127 $domain_min = $data_query->getMinimumValue();
128 $resolution = ($domain_max - $domain_min) / 100;
129
130 $events = array();
131 foreach ($raw_points as $function_idx => $points) {
132 $event_list = array();
133
134 $event_group = array();
135 $head_event = null;
136 foreach ($points as $point) {
137 $x = $point['x'];
138
139 if ($head_event === null) {
140 // We don't have any points yet, so start a new group.
141 $head_event = $x;
142 $event_group[] = $point;
143 } else if (($x - $head_event) <= $resolution) {
144 // This point is close to the first point in this group, so
145 // add it to the existing group.
146 $event_group[] = $point;
147 } else {
148 // This point is not close to the first point in the group,
149 // so create a new group.
150 $event_list[] = $event_group;
151 $head_event = $x;
152 $event_group = array($point);
153 }
154 }
155
156 if ($event_group) {
157 $event_list[] = $event_group;
158 }
159
160 $event_spec = array();
161 foreach ($event_list as $key => $event_points) {
162 // NOTE: We're using the last point as the representative point so
163 // that you can learn about a section of a chart by hovering over
164 // the point to right of the section, which is more intuitive than
165 // other points.
166 $event = last($event_points);
167
168 $event = $event + array(
169 'n' => count($event_points),
170 );
171
172 $event_list[$key] = $event;
173 }
174
175 $events[] = $event_list;
176 }
177
178 $wire_labels = array();
179 foreach ($functions as $function_key => $function) {
180 $label = $function->getFunctionLabel();
181 $wire_labels[] = $label->toWireFormat();
182 }
183
184 $result = array(
185 'type' => $this->getDatasetTypeKey(),
186 'data' => $series,
187 'events' => $events,
188 'labels' => $wire_labels,
189 );
190
191 return id(new PhabricatorChartDisplayData())
192 ->setWireData($result)
193 ->setRange(new PhabricatorChartInterval($range_min, $range_max));
194 }
195
196 private function getAllXValuesAsMap(
197 PhabricatorChartDataQuery $data_query,
198 array $point_lists) {
199
200 // We need to define every function we're drawing at every point where
201 // any of the functions we're drawing are defined. If we don't, we'll
202 // end up with weird gaps or overlaps between adjacent areas, and won't
203 // know how much we need to lift each point above the baseline when
204 // stacking the functions on top of one another.
205
206 $must_define = array();
207
208 $min = $data_query->getMinimumValue();
209 $max = $data_query->getMaximumValue();
210 $must_define[$max] = $max;
211 $must_define[$min] = $min;
212
213 foreach ($point_lists as $point_list) {
214 foreach ($point_list as $x => $point) {
215 $must_define[$x] = $x;
216 }
217 }
218
219 ksort($must_define);
220
221 return $must_define;
222 }
223
224 /**
225 * @param PhabricatorChartDataQuery $data_query
226 * @param array<PhabricatorChartFunction> $functions
227 */
228 private function getFunctionDatapoints(
229 PhabricatorChartDataQuery $data_query,
230 array $functions) {
231
232 assert_instances_of($functions, PhabricatorChartFunction::class);
233
234 $points = array();
235 foreach ($functions as $idx => $function) {
236 $points[$idx] = array();
237
238 $datapoints = $function->newDatapoints($data_query);
239 foreach ($datapoints as $point) {
240 $x_value = $point['x'];
241 $points[$idx][$x_value] = $point;
242 }
243 }
244
245 return $points;
246 }
247
248 /**
249 * @param PhabricatorChartDataQuery $data_query
250 * @param array<string<int<array<string,int>,array<string,int>>>> $point_lists
251 * The key is the stack (the PhabricatorChartFunctionLabel name of the
252 * PhabricatorChartFunction (e.g. "created" or "moved-in")) and its value
253 * is an array of keys which are date epochs and their values are another
254 * array of x:date epoch and y:incremental integer pairs:
255 * array <string<epoch<string:int,string:int>>>
256 */
257 private function getGeometry(
258 PhabricatorChartDataQuery $data_query,
259 array $point_lists) {
260
261 $must_define = $this->getAllXValuesAsMap($data_query, $point_lists);
262
263 foreach ($point_lists as $idx => $points) {
264
265 $missing = array();
266 foreach ($must_define as $x) {
267 if (!isset($points[$x])) {
268 $missing[$x] = true;
269 }
270 }
271
272 if (!$missing) {
273 continue;
274 }
275
276 $values = array_keys($points);
277 $cursor = -1;
278 $length = count($values);
279
280 foreach ($missing as $x => $ignored) {
281 // Move the cursor forward until we find the last point before "x"
282 // which is defined.
283 while ($cursor + 1 < $length && $values[$cursor + 1] < $x) {
284 $cursor++;
285 }
286
287 // If this new point is to the left of all defined points, we'll
288 // assume the value is 0. If the point is to the right of all defined
289 // points, we assume the value is the same as the last known value.
290
291 // If it's between two defined points, we average them.
292
293 if ($cursor < 0) {
294 $y = 0;
295 } else if ($cursor + 1 < $length) {
296 $xmin = $values[$cursor];
297 $xmax = $values[$cursor + 1];
298
299 $ymin = $points[$xmin]['y'];
300 $ymax = $points[$xmax]['y'];
301
302 // Fill in the missing point by creating a linear interpolation
303 // between the two adjacent points.
304 $distance = ($x - $xmin) / ($xmax - $xmin);
305 $y = $ymin + (($ymax - $ymin) * $distance);
306 } else {
307 $xmin = $values[$cursor];
308 $y = $points[$xmin]['y'];
309 }
310
311 $point_lists[$idx][$x] = array(
312 'x' => $x,
313 'y' => $y,
314 );
315 }
316
317 ksort($point_lists[$idx]);
318 }
319
320 return $point_lists;
321 }
322
323}