Sort all graphs (not just ones w/ 'other' category)
[socialanalytics:socialanalytics.git] / social.php
1 <?php
2 /**
3  * Plugin to give insights into what's happening in your social network over time.
4  *
5  * PHP version 5
6  *
7  * @category Plugin
8  * @package  StatusNet
9  * @author   Stéphane Bérubé <chimo@chromic.org>
10  * @license  http://www.fsf.org/licensing/licenses/agpl.html AGPLv3
11  * @link     http://github.com/chimo/SocialAnalytics
12  *
13  * StatusNet - the distributed open-source microblogging tool
14  * Copyright (C) 2009, StatusNet, Inc.
15  *
16  * This program is free software: you can redistribute it and/or modify
17  * it under the terms of the GNU Affero General Public License as published by
18  * the Free Software Foundation, either version 3 of the License, or
19  * (at your option) any later version.
20  *
21  * This program is distributed in the hope that it will be useful,
22  * but WITHOUT ANY WARRANTY; without even the implied warranty of
23  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
24  * GNU Affero General Public License for more details.
25  *
26  * You should have received a copy of the GNU Affero General Public License
27  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
28  */
29
30 if (!defined('STATUSNET')) {
31     exit(1);
32 }
33
34 /**
35  * Plugin to give insights into what's happening in your social network over time.
36  *
37  * @category Plugin
38  * @package  StatusNet
39  * @author   Stéphane Bérubé <chimo@chromic.org>
40  * @license  http://www.fsf.org/licensing/licenses/agpl.html AGPLv3
41  * @link     http://github.com/chimo/SocialAnalytics
42  */
43 class SocialAction extends Action
44 {
45     public $user = null;
46     public $sa   = null;
47
48     // TODO: Document
49     function sortGraph($a, $b) {
50         $c = reset($a);
51         $d = reset($b);
52
53         if(count($c) == count($d)) {
54             return ;
55         }
56
57         // DESC
58         return ($c > $d) ? -1 : 1;
59     }
60
61     /**
62      * Take arguments for running
63      *
64      * This method is called first, and it lets the action class get
65      * all its arguments and validate them. It's also the time
66      * to fetch any relevant data from the database.
67      *
68      * Action classes should run parent::prepare($args) as the first
69      * line of this method to make sure the default argument-processing
70      * happens.
71      *
72      * @param array $args $_REQUEST args
73      *
74      * @return boolean success flag
75      */
76     function prepare($args)
77     {
78         parent::prepare($args);
79
80         $this->user = common_current_user();
81
82         // Custom date range
83         $this->sa = Social_analytics::init($this->user->id, $_REQUEST['sdate'], $_REQUEST['edate']);
84
85         return true;
86     }
87
88     /**
89      * Handle request
90      *
91      * This is the main method for handling a request. Note that
92      * most preparation should be done in the prepare() method;
93      * by the time handle() is called the action should be
94      * more or less ready to go.
95      *
96      * @param array $args $_REQUEST args; handled in prepare()
97      *
98      * @return void
99      */
100     function handle($args)
101     {
102         parent::handle($args);
103         if (!common_logged_in()) {
104             // TRANS: Error message displayed when trying to perform an action that requires a logged in user.
105             $this->clientError(_('Not logged in.'));
106             return;
107         } else if (!common_is_real_login()) {
108             // Cookie theft means that automatic logins can't
109             // change important settings or see private info, and
110             // _all_ our settings are important
111             common_set_returnto($this->selfUrl());
112             $user = common_current_user();
113             if (Event::handle('RedirectToLogin', array($this, $user))) {
114                 common_redirect(common_local_url('login'), 303);
115             }
116         } else {
117             $this->showPage();
118         }
119     }
120
121     /**
122      * Title of this page
123      *
124      * Override this method to show a custom title.
125      *
126      * @return string Title of the page
127      */
128     function title()
129     {
130         return _m('Social Analytics');
131     }
132
133     function printNavigation($sdate, $edate, $location) {
134         $url = common_local_url('social');
135
136         $_sdate = clone($sdate);
137         $_edate = clone($edate);
138
139         $_sdate->modify('first day of last month');
140
141         // Prev period
142         $this->elementStart('ul', array('class' => 'social_nav social_nav_' . $location));
143         $this->elementStart('li', array('class' => 'prev'));
144         $this->element('a', array('href' => $url . '?sdate=' . $_sdate->format('Y-m-d') . '&edate=' . $_sdate->modify('last day of this month')->format('Y-m-d')), 'Previous month');
145         $this->elementEnd('li');
146
147         // Custom date range link
148         $this->elementStart('li', array('class' => 'cust'));
149         $this->element('a', array('href' => '#'), 'Custom date range');
150         
151         // Custom date range datepickers
152         $this->elementStart('form', array('class' => 'social_date_picker social_date_picker_' . $location, 'method' => 'get', 'action' => $url));
153         $this->elementStart('fieldset');
154         $this->element('label', array('for' => 'social_start_date_' . $location), 'Start date:');
155         $this->element('input', array('id' => 'social_start_date_' . $location, 'name' => 'sdate'));
156         $this->element('br');
157         $this->element('label', array('for' => 'social_end_date_' . $location), 'End date:');
158         $this->element('input', array('id' => 'social_end_date_' . $location, 'name' => 'edate'));
159         $this->element('input', array('type' => 'submit', 'id' => 'social_submit_date'));
160         $this->elementEnd('fieldset');
161         $this->elementEnd('form');        
162         
163         $this->elementEnd('li');
164         
165         // Next period
166         $_edate->modify('first day of next month');
167         $this->elementStart('li', array('class' => 'next'));
168         $this->element('a', array('href' => $url . '?sdate=' . $_edate->format('Y-m-d') . '&edate=' . $_edate->modify('last day of this month')->format('Y-m-d')), 'Next month');
169         $this->elementEnd('li');
170         $this->elementEnd('ul');
171     }
172
173
174     function printGraph($name, $rows) {
175         if(count($rows) < 1) { // Skip empty tables
176             return;
177         }
178
179         // Wrapper
180         $this->elementStart('div', array('class' => 'social_wrapper ' . $name . '_wrapper')); 
181
182         // Title
183         $this->element('h3', null, ucfirst(str_replace('_', ' ', _m($name))));
184
185         // Graph container
186         $this->element('div', array('class' => 'social_graph ' . $name . '_graph'));
187
188         // Toggle link
189         $this->element('a', array('class' => 'toggleTable', 'href' => '#'), _m('Show "' . str_replace('_', ' ', $name) . '" table'));
190
191         // Type of graph
192         $type = 'social_pie';
193         if($name == 'trends') { $type = 'social_line'; }
194         
195         // Table
196         $this->elementStart('table', array('class' => 'social_table ' . $type, 'id' => $name . '_table'));
197         $this->element('caption', null, ucfirst(str_replace('_', ' ', _m($name))));
198         $this->elementStart('thead');
199         $this->elementStart('tr');
200         $this->element('td');
201
202         // FIXME: This is hackish
203         if($name != 'trends') { // Ignore the 'trends' table since it's ok to have more than 10 rows
204             $nb_rows = count($rows);
205             uasort($rows, array($this, 'sortGraph'));
206
207             if($nb_rows > 9) { // For other tables, limit the rows to 9 and shove everything else in 'other'
208                 $other = array();
209                 $keys = array_keys($rows);
210                 for($i=9; $i<$nb_rows; $i++) {
211                     $key = array_keys($rows[$keys[$i]]);
212                     $other[$key[0]] += count($rows[$keys[$i]][$key[0]]); // Sum of items in 'other'
213                     unset($rows[$keys[$i]]); // Remove original item from array
214                 }
215                 $rows['other'] = $other; // Add 'other' to array
216             }
217         }
218
219         // Top headers
220         $foo = reset($rows);
221         foreach($foo as $bar => $meh) {
222             $this->element('th', null, $bar);
223         }
224         $this->elementEnd('tr');
225         $this->elementEnd('thead');
226
227         // Data rows
228         $this->elementStart('tbody');
229         foreach($rows as $date => $data) {
230             $this->elementStart('tr');
231             $this->element('th', null, $date);
232
233             // Data cells
234             foreach($data as $cell) {
235                 $this->elementStart('td');
236                 $this->text(count($cell));
237
238                 // Detailed information (appears onclick)
239                 if(count($cell) !== 0) {
240                     $this->elementStart('ul');
241                     switch(get_class(current($cell))) {
242                         case 'Notice':
243                             foreach($cell as $notice) {
244                                 $this->elementStart('li');
245                                 $this->raw($notice->rendered);
246                                 $this->elementEnd('li');
247                             }
248                             break;
249                         case 'Profile':
250                             foreach($cell as $follower) {
251                                 $this->elementStart('li');
252                                 $this->text($follower->nickname);
253                                 $this->elementEnd('li');
254                             }
255                             break;
256                     }
257                     $this->elementEnd('ul');
258                 }
259
260                 $this->elementEnd('td');
261             }
262             $this->elementEnd('tr');
263         }
264         $this->elementEnd('tbody');
265         $this->elementEnd('table');
266
267         $this->elementEnd('div'); // Wrapper
268     }
269
270     /**
271      * Show content in the content area
272      *
273      * The default StatusNet page has a lot of decorations: menus,
274      * logos, tabs, all that jazz. This method is used to show
275      * content in the content area of the page; it's the main
276      * thing you want to overload.
277      *
278      * This method also demonstrates use of a plural localized string.
279      *
280      * @return void
281      */
282     function showContent()
283     {
284         // Month
285         $this->element('h2', null, sprintf(_m('From %s to %s'), $this->sa->sdate->format('Y-m-d'), $this->sa->edate->format('Y-m-d')));
286
287         // Navigation
288         $this->printNavigation($this->sa->sdate, $this->sa->edate, 'top');
289
290         // JS switcher
291         $jslibs = array();
292         if($fd = opendir(dirname(__FILE__) . '/js/lib')) {
293             while (false !== ($entry = readdir($fd))) {
294                 if ($entry == '.' || $entry == '..') { // FIXME: Probably want to just include files ending in '.js'
295                     continue;
296                 }
297                 $jslibs[] = $entry; // TODO: Just keep what comes before the 1st '.' in the filename
298             }
299             closedir($fd);
300
301             $this->elementStart('select', array('id' => 'social_js_switcher', 'style' => (count($jslibs) > 1) ? '' : 'display: none;'));
302             foreach($jslibs as $jslib) {
303                 $this->element('option', array('value' => $jslib), $jslib);
304             }
305             $this->elementEnd('select');
306         }
307
308         // Summary
309         $this->element('h3', null, 'Summary');
310         $this->element('p', array('class' => 'summary'), 'During this time, you:');
311         $this->elementStart('ul', array('class' => 'summary'));
312
313         $this->elementStart('li', array('class' => 'posts'));
314         $this->text('posted ' . $this->sa->ttl_notices . ' notice(s). (Daily avg: ' . round($this->sa->ttl_notices/count($this->sa->graphs['trends'])) . ')');
315         $this->elementEnd('li');
316
317         $this->elementStart('li', array('class' => 'bookmarks'));
318         $this->text('posted ' . $this->sa->ttl_bookmarks . ' bookmarks(s)');
319         $this->elementEnd('li');
320
321         $this->elementStart('li', array('class' => 'follow'));
322         $this->text('followed ' . $this->sa->ttl_following . ' new people');
323         $this->elementEnd('li');
324
325         $this->elementStart('li', array('class' => 'follow'));
326         $this->text('gained ' . $this->sa->ttl_followers . ' followers');
327         $this->elementEnd('li');
328
329         $this->elementStart('li', array('class' => 'favs'));
330         $this->text('favorited ' . $this->sa->ttl_faves . ' notices');
331         $this->elementEnd('li');
332
333         $this->elementStart('li', array('class' => 'favs'));
334         $this->text('had people favor your notices ' . $this->sa->ttl_o_faved . ' times');
335         $this->elementEnd('li');
336
337         $this->elementStart('li', array('class' => 'replies'));
338         $this->text('were mentioned ' . $this->sa->ttl_mentions . ' times, by ' . count($this->sa->graphs['people_who_mentioned_you']) . ' different people');
339         $this->elementEnd('li');
340
341         $this->elementStart('li', array('class' => 'replies'));
342         $this->text('replied to ' . count($this->sa->graphs['people_you_replied_to']) . ' people, for a total of ' . $this->sa->ttl_replies . ' replies');
343         $this->elementEnd('li');        
344         
345         $this->elementEnd('ul');
346
347         // Graphs
348         foreach($this->sa->graphs as $title => $graph) {
349                 $this->printGraph($title, $graph);
350         }
351
352         // If we have map data
353         if(count($this->sa->map)) {
354             // Wrapper
355             $this->elementStart('div', array('class' => 'social_map_wrapper'));
356
357             // Print Map title
358             $this->element('h3', null, 'Location of new subscriptions');
359             $this->element('p', null, 'Red: you started following, blue: started to follow you');
360
361             // Map container
362             $this->element('div', array('id' => 'mapdiv'));
363
364             // JS variables (used by js/map.js)
365             $this->inlineScript('var sa_following_coords = ' . $this->getCoords('following') . ';
366                 var sa_followers_coords = ' . $this->getCoords('followers') . ';');
367
368             $this->elementEnd('div'); // Wrapper
369         }
370
371         // Navigation
372         $this->printNavigation($this->sa->sdate, $this->sa->edate, 'bottom');
373     }
374
375     function getCoords($name) {
376         $markers = '[';
377
378         // FIXME: Just store this in JS notation in $this->sa->map['following']['nickname'] to being with
379         foreach($this->sa->map[$name] as $nickname => $coords) {
380             $markers .= '{ lon: "' . $coords['lon'] . '", lat: "'  . $coords['lat'] . '", nickname: "' . $nickname . '"},';
381         }
382
383         $markers = rtrim($markers, ',');
384         return $markers . ']';
385     }
386
387     /**
388      * Return true if read only.
389      *
390      * Some actions only read from the database; others read and write.
391      * The simple database load-balancer built into StatusNet will
392      * direct read-only actions to database mirrors (if they are configured),
393      * and read-write actions to the master database.
394      *
395      * This defaults to false to avoid data integrity issues, but you
396      * should make sure to overload it for performance gains.
397      *
398      * @param array $args other arguments, if RO/RW status depends on them.
399      *
400      * @return boolean is read only action?
401      */
402     function isReadOnly($args)
403     {
404         return true;
405     }
406 }