4
0

Insights.pm 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. package Pisg::HTMLGenerator;
  2. # Extra sections for the stats page, added to Pisg::HTMLGenerator:
  3. #
  4. # overview key numbers at a glance
  5. # relations who talks to whom: interactive map, closest pairs, social roles
  6. # time personalities night owls, early birds ...
  7. # concentration how much of the channel the top talkers write
  8. # signature words the word each regular owns
  9. # navbar a sticky bar to jump to any section
  10. #
  11. # Everything here reads data pisg already collected (plus who-talks-to-whom from
  12. # Pisg::Parser::Logfile). Each section has its own Show... option.
  13. use strict;
  14. use warnings;
  15. use Pisg::Common;
  16. use JSON::PP ();
  17. use Encode ();
  18. my @BUCKET_KEYS = qw(rel_time0 rel_time1 rel_time2 rel_time3);
  19. # ---- small helpers ---------------------------------------------------------------
  20. # Run one of the extra sections; if it fails, say so and carry on. The rest of the page
  21. # is more important than any one extra section.
  22. sub _ins_safe
  23. {
  24. my ($self, $method) = @_;
  25. return if eval { $self->$method(); 1 };
  26. my $err = $@;
  27. $err =~ s/\s+$//;
  28. print STDERR "Warning: skipped the '$method' section: $err\n";
  29. }
  30. sub _ins_num
  31. {
  32. my ($self, $n) = @_;
  33. $n = int($n + 0.5);
  34. 1 while $n =~ s/^(-?\d+)(\d{3})/$1,$2/;
  35. return $n;
  36. }
  37. sub _ins_esc { my ($self, $t) = @_; return htmlentities($t, $self->{cfg}->{charset}); }
  38. # A "word" needs at least two real letters: ASCII art, punctuation and lone symbols are not words.
  39. sub _has_letters
  40. {
  41. my ($self, $w) = @_;
  42. my $u = eval { Encode::decode('UTF-8', $w, Encode::FB_CROAK | Encode::LEAVE_SRC) };
  43. $u = $w unless defined $u;
  44. return $u =~ /\p{L}{2}/ ? 1 : 0;
  45. }
  46. sub _ins_isbot
  47. {
  48. my ($self, $nick) = @_;
  49. return 1 if is_ignored($nick);
  50. my $sex = $self->{users}->{sex}{$nick};
  51. return ($sex && $sex eq 'b') ? 1 : 0;
  52. }
  53. # Nicks that are people (not ignored, not marked as bots), busiest first.
  54. sub _ins_humans
  55. {
  56. my $self = shift;
  57. return $self->{ins_humans} ||= do {
  58. my $l = $self->{stats}->{lines};
  59. [ sort { $l->{$b} <=> $l->{$a} or $a cmp $b } grep { !$self->_ins_isbot($_) } keys %$l ];
  60. };
  61. }
  62. # How many lines a nick needs before we rank it on things like "night owl".
  63. sub _ins_minlines
  64. {
  65. my $self = shift;
  66. my $t = $self->{cfg}->{bignumbersthreshold};
  67. $t = 0 unless defined $t and $t =~ /^\d+(?:\.\d+)?$/;
  68. return $t < 20 ? 20 : ($t > 100 ? 100 : $t);
  69. }
  70. sub _ins_json
  71. {
  72. my ($self, $data) = @_;
  73. # Bytes in, bytes out (no utf8 layer), like the rest of the page. The escapes stop
  74. # anything in a nick from closing the <script> element or starting a tag.
  75. my $json = JSON::PP->new->canonical->encode($data);
  76. $json =~ s/</\\u003c/g;
  77. $json =~ s/>/\\u003e/g;
  78. $json =~ s/&/\\u0026/g;
  79. return $json;
  80. }
  81. # Small CSS/JS files live next to the themes (layout/). Read like a stylesheet.
  82. sub _ins_slurp
  83. {
  84. my ($self, $name) = @_;
  85. my $file = $self->{cfg}->{cssdir} . $name;
  86. my $fh;
  87. open($fh, '<', $file) or open($fh, '<', $self->{cfg}->{search_path} . "/$file") or return '';
  88. local $/;
  89. my $text = <$fh>;
  90. close($fh);
  91. return $text;
  92. }
  93. # ---- navbar ----------------------------------------------------------------------
  94. # Sections that read fine in half the width. On a wide screen two of them sit side by side; the tables
  95. # and charts that need the whole width keep it. The key is the language-independent template name.
  96. my %HALF = map { $_ => 1 } qw(bignumtopic othernumtopic latesttopic mostwordstopic referencetopic
  97. smileytopic karmatopic urlstopic chartstopic mostnickstopic activegenderstopic
  98. concentrationtopic signaturetopic);
  99. sub _card_is_half { my ($self, $key) = @_; return defined $key && $HALF{$key} ? 1 : 0; }
  100. # Shorten a label to $max characters, at a word boundary, without ever cutting a
  101. # multi-byte UTF-8 character in half.
  102. sub _ins_cut
  103. {
  104. my ($self, $text, $max) = @_;
  105. my $utf8 = $self->{cfg}->{charset} =~ /^utf-?8$/i;
  106. my $chars = $utf8 ? Encode::decode('UTF-8', $text, Encode::FB_DEFAULT) : $text;
  107. return $text if length($chars) <= $max;
  108. $chars = substr($chars, 0, $max);
  109. $chars =~ s/\s+\S*$// if $chars =~ /\s/; # back up to the last whole word
  110. $chars .= '...';
  111. return $utf8 ? Encode::encode('UTF-8', $chars) : $chars;
  112. }
  113. # Called by _headline for every section: remember it so the navbar can link to it.
  114. sub _nav_register
  115. {
  116. my ($self, $title, $id, $label) = @_;
  117. my $plain = $title;
  118. $plain =~ s/<[^>]*>//g;
  119. unless (defined $label and length $label) {
  120. $label = $plain;
  121. $label =~ s/\s*\(.*$//; # "Daily activity (last 31 days)" -> "Daily activity"
  122. $label =~ s/^\s+|\s+$//g;
  123. $label = $self->_ins_cut($label, 30);
  124. }
  125. my $n = scalar @{ $self->{nav} ||= [] } + 1;
  126. unless (defined $id and length $id) {
  127. # A readable id from the title, but not when the title has accents or another script
  128. # (they would be mangled): those get a plain number instead.
  129. if ($plain =~ /[\x80-\xFF]/) {
  130. $id = '';
  131. } else {
  132. ($id = lc $plain) =~ s/[^a-z0-9]+/-/g;
  133. $id =~ s/^-+|-+$//g;
  134. }
  135. }
  136. $id = "section-$n" unless length $id;
  137. $id = "$id-$n" if grep { $_->{id} eq $id } @{ $self->{nav} };
  138. push @{ $self->{nav} }, { id => $id, label => $label, full => $plain };
  139. return $id;
  140. }
  141. # The marker sits where the bar goes; it is replaced once every section is known.
  142. sub _nav_marker { my $self = shift; _html('<!--PISG_NAV-->') if $self->_nav_wanted; }
  143. sub _nav_wanted
  144. {
  145. my $self = shift;
  146. return $self->{cfg}->{shownavbar} && $self->{cfg}->{colorscheme} ne 'none';
  147. }
  148. sub _insert_navbar
  149. {
  150. my $self = shift;
  151. return unless $self->_nav_wanted;
  152. my @items = @{ $self->{nav} || [] };
  153. my $path = $self->{outfile_tmp} or return; # the page is finished in a temp file, see create_output
  154. open(my $in, '<', $path) or return;
  155. local $/;
  156. my $page = <$in>;
  157. close($in);
  158. my $bar = '';
  159. if (@items > 1) {
  160. # Two lists from the same links; the stylesheet shows one of them.
  161. # * narrow screens: one slim bar (where you are + a button that opens the whole list), a
  162. # native <details>, so it works even without JavaScript;
  163. # * wide screens: a fixed menu down the left side, always open.
  164. my $label = $self->_template_text('nav_label');
  165. my $chan = $self->_ins_esc($self->{cfg}->{channel});
  166. my $links = join('', map { '<li><a href="#' . $_->{id} . '" title="' . $self->_ins_esc($_->{full} || $_->{label}) . '">'
  167. . $self->_ins_esc($_->{label}) . '</a></li>' } @items);
  168. $bar = '<nav class="pisg-nav" id="pisg-nav" aria-label="' . $label . '"><details class="nav-menu">'
  169. . '<summary><span class="nav-current" id="nav-current">' . $self->_ins_esc($items[0]{label}) . '</span>'
  170. . '<span class="nav-toggle">' . $label . '</span></summary><ul class="nav-list">' . $links . '</ul></details>'
  171. . '<div class="nav-side"><a class="nav-brand" href="#pagetitle1">' . $chan . '</a>'
  172. . '<ul class="nav-side-list">' . $links . '</ul></div></nav>';
  173. }
  174. $page =~ s/<!--PISG_NAV-->/$bar/;
  175. if (open(my $out, '>', $path)) {
  176. print $out $page;
  177. close($out);
  178. }
  179. }
  180. # ---- CSS for pages whose theme has never heard of these sections ---------------------
  181. sub _ins_basecss { my $self = shift; return $self->_ins_slurp('insights-base.css'); }
  182. # ---- overview --------------------------------------------------------------------
  183. sub _insights_overview
  184. {
  185. my $self = shift;
  186. my $st = $self->{stats};
  187. my $humans = $self->_ins_humans;
  188. return unless @$humans;
  189. my ($lines, $words, $questions, $joins, $kicks, $links, $distinct) = (0) x 7;
  190. $lines += $_ for values %{ $st->{lines} || {} };
  191. $words += $_ for values %{ $st->{words} || {} };
  192. $questions += $_ for values %{ $st->{questions} || {} };
  193. $joins += $_ for values %{ $st->{joins} || {} };
  194. $kicks += $_ for values %{ $st->{kicked} || {} };
  195. for my $u (values %{ $st->{urlcounts} || {} }) { $links += $u; $distinct++; }
  196. return unless $lines;
  197. my $days = $st->{days} || 1;
  198. my $times = $st->{times} || {};
  199. my $total = 0;
  200. $total += $_ for values %$times;
  201. my ($busy, $quiet) = (0, 0);
  202. my ($bmax, $qmin) = (-1, undef);
  203. for my $h (0 .. 23) {
  204. my $v = $times->{ sprintf('%02d', $h) } || 0;
  205. ($bmax, $busy) = ($v, $h) if $v > $bmax;
  206. ($qmin, $quiet) = ($v, $h) if !defined $qmin or $v < $qmin;
  207. }
  208. my $pct = sub { my ($n, $d) = @_; return $d ? sprintf('%.1f', 100 * $n / $d) : '0.0' };
  209. my $hourfmt = sub { my $h = shift; return sprintf('%02d:00-%02d:00', $h, ($h + 1) % 24) };
  210. my $top = $humans->[0];
  211. # The first three numbers become the headline sentence; the rest is a list of facts.
  212. my @facts = (
  213. [ 'ov_words', $self->_ins_num($words), '' ],
  214. [ 'ov_lpd', $self->_ins_num($lines / $days), '' ],
  215. [ 'ov_busy', $hourfmt->($busy), $self->_template_text('ov_pct_sub', pct => $pct->($bmax, $total)) ],
  216. [ 'ov_quiet', $hourfmt->($quiet), $self->_template_text('ov_pct_sub', pct => $pct->($qmin, $total)) ],
  217. [ 'ov_questions', $self->_ins_num($questions), $self->_template_text('ov_pct_lines_sub', pct => $pct->($questions, $lines)) ],
  218. [ 'ov_links', $self->_ins_num($links), $self->_template_text('ov_links_sub', distinct => $self->_ins_num($distinct)) ],
  219. [ 'ov_top', $self->_ins_esc($top), $self->_template_text('ov_pct_lines_sub', pct => $pct->($st->{lines}{$top}, $lines)) ],
  220. );
  221. push @facts, [ 'ov_joins', $self->_ins_num($joins), '' ] if $joins;
  222. push @facts, [ 'ov_kicks', $self->_ins_num($kicks), '' ] if $kicks;
  223. $self->_headline($self->_template_text('overviewtopic'), 'overview');
  224. _html('<p class="hero">' . $self->_template_text('ov_hero',
  225. lines => '<b>' . $self->_ins_num($lines) . '</b>',
  226. nicks => '<b>' . $self->_ins_num(scalar keys %{ $st->{lines} }) . '</b>',
  227. days => '<b>' . $self->_ins_num($days) . '</b>') . '</p>');
  228. _html('<dl class="facts">');
  229. for my $f (@facts) {
  230. _html('<div class="fact"><dt>' . $self->_template_text($f->[0]) . '</dt><dd>' . $f->[1]
  231. . (length $f->[2] ? '<small>' . $f->[2] . '</small>' : '') . '</dd></div>');
  232. }
  233. _html('</dl>');
  234. }
  235. # ---- who talks to whom -------------------------------------------------------------
  236. # Strength of a connection: a line addressed to someone counts most, then a mention,
  237. # then answering right after them.
  238. sub _ins_strength { my ($self, $d, $m, $x) = @_; return 3 * $d + $m + $x; }
  239. # Everything the relation sections need, worked out once.
  240. sub _ins_relations
  241. {
  242. my $self = shift;
  243. return $self->{ins_rel} if $self->{ins_rel};
  244. my $st = $self->{stats};
  245. my $rel = $st->{relations} || {};
  246. my %isbot;
  247. my %pair; # "a\0b" (a lt b) => {a,b, ab=>[d,m,x], ba=>[d,m,x]}
  248. my (%in, %out, %listen, %partners); # per nick
  249. for my $from (keys %$rel) {
  250. next if $isbot{$from} //= $self->_ins_isbot($from);
  251. for my $to (keys %{ $rel->{$from} }) {
  252. next if $isbot{$to} //= $self->_ins_isbot($to);
  253. my ($d, $m, $x) = @{ $rel->{$from}{$to} };
  254. my ($lo, $hi) = $from lt $to ? ($from, $to) : ($to, $from);
  255. my $p = $pair{"$lo\0$hi"} ||= { a => $lo, b => $hi, ab => [0, 0, 0], ba => [0, 0, 0] };
  256. my $slot = $from eq $lo ? 'ab' : 'ba';
  257. my @v = ($d, $m, $x);
  258. $p->{$slot}[$_] += $v[$_] for 0 .. 2;
  259. }
  260. }
  261. for my $p (values %pair) {
  262. my ($ab, $ba) = ($p->{ab}, $p->{ba});
  263. $p->{w} = $self->_ins_strength($ab->[0] + $ba->[0], $ab->[1] + $ba->[1], $ab->[2] + $ba->[2]);
  264. my ($lo, $hi) = ($p->{a}, $p->{b});
  265. $out{$lo} += 3 * $ab->[0] + $ab->[1]; $out{$hi} += 3 * $ba->[0] + $ba->[1];
  266. $in{$hi} += 3 * $ab->[0] + $ab->[1]; $in{$lo} += 3 * $ba->[0] + $ba->[1];
  267. $listen{$hi} += $ab->[2]; $listen{$lo} += $ba->[2]; # x for lo->hi = hi answered lo
  268. push @{ $partners{$lo} }, [$hi, $p->{w}];
  269. push @{ $partners{$hi} }, [$lo, $p->{w}];
  270. }
  271. $self->{ins_rel} = { pair => \%pair, in => \%in, out => \%out, listen => \%listen, partners => \%partners };
  272. return $self->{ins_rel};
  273. }
  274. sub _insights_relations
  275. {
  276. my $self = shift;
  277. my $st = $self->{stats};
  278. return unless $st->{relations} and %{ $st->{relations} };
  279. my $r = $self->_ins_relations;
  280. my $humans = $self->_ins_humans;
  281. my $limit = $self->{cfg}->{relationnicks} || 30;
  282. my @nodes = @$humans[0 .. ($limit > @$humans ? $#$humans : $limit - 1)];
  283. return if @nodes < 2;
  284. my %idx;
  285. @idx{@nodes} = 0 .. $#nodes;
  286. my $minw = $self->{cfg}->{relationminweight};
  287. my @edges;
  288. for my $p (sort { $b->{w} <=> $a->{w} } values %{ $r->{pair} }) {
  289. next unless exists $idx{ $p->{a} } and exists $idx{ $p->{b} } and $p->{w} >= $minw;
  290. push @edges, { a => $idx{ $p->{a} }, b => $idx{ $p->{b} }, w => $p->{w}, ab => $p->{ab}, ba => $p->{ba} };
  291. last if @edges >= 150;
  292. }
  293. return unless @edges;
  294. my @jnodes;
  295. for my $n (@nodes) {
  296. my @top = sort { $b->[1] <=> $a->[1] } @{ $r->{partners}{$n} || [] };
  297. splice(@top, 5) if @top > 5;
  298. push @jnodes, {
  299. id => $n,
  300. lines => 0 + $st->{lines}{$n},
  301. words => 0 + ($st->{words}{$n} || 0),
  302. hours => [ map { 0 + ($_ || 0) } @{ $st->{line_times}{$n} || [] }[0 .. 3] ],
  303. partners => [ map { [ $_->[0], 0 + $_->[1] ] } @top ],
  304. };
  305. }
  306. my $i18n = { map { $_ => $self->_template_text($_) } qw(
  307. rel_pick rel_lines rel_words rel_active rel_partners rel_between rel_toward rel_strength
  308. rel_direct rel_mentions rel_replies rel_time0 rel_time1 rel_time2 rel_time3 rel_nolinks ) };
  309. $self->_headline($self->_template_text('relationstopic'), 'relations');
  310. _html('<p class="intro">' . $self->_template_text('rel_intro') . '</p>');
  311. _html('<div class="relmap" id="relmap">'
  312. . '<div class="relmap-canvas"><svg id="relmap-svg" role="img" aria-label="' . $self->_template_text('relationstopic')
  313. . '" viewBox="0 0 960 620" preserveAspectRatio="xMidYMid meet"></svg></div>'
  314. . '<div class="relmap-side"><div class="relmap-legend">'
  315. . join('', map { '<span class="lg lg' . $_ . '"><i></i>' . $self->_template_text($BUCKET_KEYS[$_]) . '</span>' } 0 .. 3)
  316. . '</div><div class="relmap-info" id="relmap-info" aria-live="polite">' . $self->_template_text('rel_pick') . '</div></div>'
  317. . '</div>');
  318. _html('<noscript><p class="intro">' . $self->_template_text('rel_nojs') . '</p></noscript>');
  319. _html('<script type="application/json" id="relmap-data">' . $self->_ins_json({ nodes => \@jnodes, edges => \@edges, i18n => $i18n }) . '</script>');
  320. $self->{ins_script_relmap} = 1;
  321. $self->_insights_pairs;
  322. $self->_insights_roles;
  323. }
  324. sub _insights_pairs
  325. {
  326. my $self = shift;
  327. my $r = $self->_ins_relations;
  328. my @top = sort { $b->{w} <=> $a->{w} or $a->{a} cmp $b->{a} } values %{ $r->{pair} };
  329. my $max = $self->{cfg}->{nickhistory} > 10 ? $self->{cfg}->{nickhistory} : 10;
  330. splice(@top, $max) if @top > $max;
  331. return unless @top;
  332. $self->_headline($self->_template_text('pairstopic'), 'pairs');
  333. _html('<table border="0" width="' . $self->{cfg}->{tablewidth} . '"><tr><td>&nbsp;</td>'
  334. . '<td class="tdtop"><b>' . $self->_template_text('pair') . '</b></td>'
  335. . '<td class="tdtop"><b>' . $self->_template_text('rel_strength') . '</b></td>'
  336. . '<td class="tdtop"><b>' . $self->_template_text('pair_detail') . '</b></td></tr>');
  337. my $i = 0;
  338. for my $p (@top) {
  339. $i++;
  340. my $wid = int(100 * $p->{w} / $top[0]{w});
  341. my ($ab, $ba) = ($p->{ab}, $p->{ba});
  342. my $detail = $self->_template_text('pair_line', a => $self->_ins_esc($p->{a}), b => $self->_ins_esc($p->{b}),
  343. ad => $ab->[0], am => $ab->[1], bd => $ba->[0], bm => $ba->[1], replies => $ab->[2] + $ba->[2]);
  344. _html('<tr><td class="' . ($i == 1 ? 'hirankc' : 'rankc') . '" align="left">' . $i . '</td>'
  345. . '<td class="hicell"><b>' . $self->_ins_esc($p->{a}) . '</b> &harr; <b>' . $self->_ins_esc($p->{b}) . '</b></td>'
  346. . '<td class="hicell"><div class="bar"><span style="width:' . ($wid || 1) . '%"></span></div> ' . $self->_ins_num($p->{w}) . '</td>'
  347. . '<td class="hicell small">' . $detail . '</td></tr>');
  348. }
  349. _html('</table>');
  350. }
  351. sub _insights_roles
  352. {
  353. my $self = shift;
  354. my $st = $self->{stats};
  355. my $r = $self->_ins_relations;
  356. my $min = $self->_ins_minlines;
  357. my @pool = grep { $st->{lines}{$_} >= $min } @{ $self->_ins_humans };
  358. return unless @pool >= 3;
  359. my $best = sub {
  360. my ($score, $desc) = @_; # highest score wins
  361. my ($win, $ws) = (undef, undef);
  362. for my $n (@pool) {
  363. my $s = $score->($n);
  364. ($win, $ws) = ($n, $s) if defined $s and (!defined $ws or $s > $ws);
  365. }
  366. return ($win, $ws);
  367. };
  368. my $strong = sub { # partners with a real connection
  369. my $n = shift;
  370. return scalar grep { $_->[1] >= 5 } @{ $r->{partners}{$n} || [] };
  371. };
  372. my @roles;
  373. my ($n, $v);
  374. ($n, $v) = $best->(sub { $r->{in}{ $_[0] } || 0 });
  375. push @roles, [ 'role_talkedto', $n, $self->_template_text('role_talkedto_v', n => $self->_ins_num($v)) ] if $n and $v;
  376. ($n, $v) = $best->(sub { $r->{out}{ $_[0] } ? 100 * $r->{out}{ $_[0] } / (3 * $st->{lines}{ $_[0] }) : 0 });
  377. push @roles, [ 'role_outgoing', $n, $self->_template_text('role_outgoing_v', pct => sprintf('%.0f', $v)) ] if $n and $v;
  378. ($n, $v) = $best->(sub { $strong->($_[0]) });
  379. push @roles, [ 'role_connector', $n, $self->_template_text('role_connector_v', n => $v) ] if $n and $v;
  380. my $connector = $n;
  381. ($n, $v) = $best->(sub { $r->{listen}{ $_[0] } || 0 });
  382. push @roles, [ 'role_listener', $n, $self->_template_text('role_listener_v', n => $self->_ins_num($v)) ] if $n and $v;
  383. # Lone wolf: someone with fewer close contacts than the typical regular, never the connector.
  384. # (Lines per contact alone always picks the busiest nick, who usually knows everybody.)
  385. my @contacts = sort { $a <=> $b } map { $strong->($_) } @pool;
  386. my $median = $contacts[ int($#contacts / 2) ];
  387. ($n, $v) = $best->(sub {
  388. my $c = $strong->($_[0]);
  389. return undef if defined $connector and $_[0] eq $connector or $c >= $median;
  390. return $st->{lines}{ $_[0] } / (1 + $c);
  391. });
  392. push @roles, [ 'role_lonewolf', $n, $self->_template_text('role_lonewolf_v', lines => $self->_ins_num($st->{lines}{$n}), n => $strong->($n)) ] if $n;
  393. return unless @roles;
  394. $self->_headline($self->_template_text('rolestopic'), 'roles');
  395. _html('<div class="cardgrid">');
  396. for my $ro (@roles) {
  397. _html('<div class="minicard"><span class="mc-title">' . $self->_template_text($ro->[0]) . '</span>'
  398. . '<span class="mc-name">' . $self->_ins_esc($ro->[1]) . '</span>'
  399. . '<span class="mc-desc">' . $self->_template_text($ro->[0] . '_d') . '</span>'
  400. . '<span class="mc-value">' . $ro->[2] . '</span></div>');
  401. }
  402. _html('</div>');
  403. }
  404. # ---- time personalities -------------------------------------------------------------
  405. sub _insights_personalities
  406. {
  407. my $self = shift;
  408. my $st = $self->{stats};
  409. my $min = $self->_ins_minlines;
  410. my @pool = grep { $st->{lines}{$_} >= $min and $st->{line_times}{$_} } @{ $self->_ins_humans };
  411. return unless @pool >= 4;
  412. my @cols;
  413. for my $bk (0 .. 3) {
  414. my @rank;
  415. for my $n (@pool) {
  416. my $sum = 0;
  417. $sum += $_ || 0 for @{ $st->{line_times}{$n} }[0 .. 3];
  418. next unless $sum;
  419. push @rank, [ $n, 100 * ($st->{line_times}{$n}[$bk] || 0) / $sum ];
  420. }
  421. @rank = sort { $b->[1] <=> $a->[1] or $st->{lines}{ $b->[0] } <=> $st->{lines}{ $a->[0] } or $a->[0] cmp $b->[0] } @rank;
  422. splice(@rank, 3) if @rank > 3;
  423. push @cols, \@rank;
  424. }
  425. $self->_headline($self->_template_text('persontopic'), 'personalities');
  426. _html('<div class="cardgrid">');
  427. my @names = qw(per_night per_morning per_afternoon per_evening);
  428. for my $bk (0 .. 3) {
  429. my ($from, $to) = ($bk * 6, $bk * 6 + 5);
  430. _html('<div class="minicard t' . $bk . '"><span class="mc-title">' . $self->_template_text($names[$bk]) . '</span>'
  431. . '<span class="mc-desc">' . $self->_template_text('per_range', from => $from, to => $to) . '</span><ol class="mc-list">'
  432. . join('', map { '<li><b>' . $self->_ins_esc($_->[0]) . '</b> <span>' . sprintf('%.0f', $_->[1]) . '%</span></li>' } @{ $cols[$bk] })
  433. . '</ol></div>');
  434. }
  435. _html('</div>');
  436. }
  437. # ---- concentration ------------------------------------------------------------------
  438. sub _insights_concentration
  439. {
  440. my $self = shift;
  441. my $st = $self->{stats};
  442. my @humans = @{ $self->_ins_humans };
  443. return unless @humans >= 3;
  444. my $total = 0;
  445. $total += $st->{lines}{$_} for @humans;
  446. return unless $total;
  447. my ($run, $half, %at) = (0, 0);
  448. my $i = 0;
  449. for my $n (@humans) {
  450. $run += $st->{lines}{$n};
  451. $i++;
  452. $at{$i} = 100 * $run / $total;
  453. $half ||= $i if $run * 2 >= $total;
  454. }
  455. my @steps = grep { $_ <= @humans } (1, 3, 5, 10, 20);
  456. $self->_headline($self->_template_text('concentrationtopic'), 'concentration', undef, 'concentrationtopic');
  457. _html('<p class="intro">' . $self->_template_text('conc_half', n => $half) . '</p>');
  458. _html('<table border="0" width="' . $self->{cfg}->{tablewidth} . '">');
  459. for my $s (@steps) {
  460. _html('<tr><td class="hicell" style="width:9em">' . $self->_template_text('conc_top', n => $s) . '</td>'
  461. . '<td class="hicell"><div class="bar"><span style="width:' . sprintf('%.1f', $at{$s}) . '%"></span></div></td>'
  462. . '<td class="hicell" style="width:5em">' . sprintf('%.1f', $at{$s}) . '%</td></tr>');
  463. }
  464. _html('</table>');
  465. }
  466. # ---- signature words ------------------------------------------------------------------
  467. sub _insights_signature
  468. {
  469. my $self = shift;
  470. my $st = $self->{stats};
  471. my $sig = $st->{signature} || {};
  472. my @nicks = grep { $sig->{$_} and !$self->_ins_isbot($_) } @{ $self->_ins_humans };
  473. return unless @nicks;
  474. splice(@nicks, 15) if @nicks > 15;
  475. $self->_headline($self->_template_text('signaturetopic'), 'signature', undef, 'signaturetopic');
  476. _html('<p class="intro">' . $self->_template_text('sig_intro') . '</p>');
  477. _html('<table border="0" width="' . $self->{cfg}->{tablewidth} . '"><tr><td>&nbsp;</td>'
  478. . '<td class="tdtop"><b>' . $self->_template_text('nick') . '</b></td>'
  479. . '<td class="tdtop"><b>' . $self->_template_text('sig_word') . '</b></td>'
  480. . '<td class="tdtop"><b>' . $self->_template_text('sig_uses') . '</b></td>'
  481. . '<td class="tdtop"><b>' . $self->_template_text('sig_share') . '</b></td></tr>');
  482. my $i = 0;
  483. for my $n (@nicks) {
  484. my ($word, $uses, $share) = @{ $sig->{$n} };
  485. $i++;
  486. _html('<tr><td class="' . ($i == 1 ? 'hirankc' : 'rankc') . '" align="left">' . $i . '</td>'
  487. . '<td class="hicell"><b>' . $self->_ins_esc($n) . '</b></td>'
  488. . '<td class="hicell sigword">' . $self->_ins_esc($word) . '</td>'
  489. . '<td class="hicell">' . $self->_ins_num($uses) . '</td>'
  490. . '<td class="hicell"><div class="bar"><span style="width:' . $share . '%"></span></div> ' . $share . '%</td></tr>');
  491. }
  492. _html('</table>');
  493. }
  494. # ---- scripts, written once near the end of the page --------------------------------------
  495. sub _insights_scripts
  496. {
  497. my $self = shift;
  498. return unless $self->_nav_wanted or $self->{ins_script_relmap};
  499. if ($self->{ins_script_relmap}) {
  500. my $js = $self->_ins_slurp('relmap.js');
  501. _html("<script>\n$js\n</script>") if length $js;
  502. }
  503. if ($self->_nav_wanted) {
  504. my $js = $self->_ins_slurp('pagenav.js');
  505. _html("<script>\n$js\n</script>") if length $js;
  506. }
  507. }
  508. 1;