Diff -- /var/www/vhosts/3dshawn.com/site1/MODS/Charts.pm

O Operator
Diff

/var/www/vhosts/3dshawn.com/site1/MODS/Charts.pm

modified on local at 2026-07-10 23:21:50

Added
+42
lines
Removed
-10
lines
Context
195
unchanged
Blobs
from 73cae7c6d547
to 4b17b4998769
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11package MODS::Charts;
22#======================================================================
33# DriftSense -- hand-rolled SVG chart helpers (zero external deps).
44#
55# All charts are inline SVG strings that use CSS variables from
66# drift_sense.css so palette + theme changes propagate automatically.
77#
88# Public functions (call as MODS::Charts::stacked_bars(...), etc.):
99#
1010# stacked_bars(days => \@days, series => { file => \@nums, schema => \@nums })
1111# Wide activity timeline. One bar per day, stacked by series.
1212#
1313# sparkline(values => \@nums, w => 80, h => 20)
1414# Tiny inline SVG summary line. Great next to file / table names.
1515#
1616# simple_donut(size, values => [{v, color, label}], center)
1717# Compact donut for savings / drift / dedup ratios.
1818#======================================================================
1919use strict;
2020use warnings;
2121
2222sub _esc {
2323 my $s = shift; $s //= '';
2424 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
2525 return $s;
2626}
2727
2828#---------------------------------------------------------------------
2929# stacked_bars({ days => [...], series => { file => [...], schema => [...] } })
3030#
3131# @days = arrayref of day labels (oldest -> newest). @series entries
3232# must be same-length arrayrefs of integers. Bars stack file (teal)
3333# below schema (amber). Y-axis auto-scales.
3434#---------------------------------------------------------------------
3535sub stacked_bars {
3636 my %a = @_;
37 my $days = $a{days} || [];
38 my $series = $a{series} || {};
39 my $w = $a{width} || 900;
40 my $h = $a{height} || 160;
37 my $days = $a{days} || []; # display labels e.g. "Jul 10"
38 my $day_keys = $a{day_keys} || []; # ISO YYYY-MM-DD for links/tooltips
39 my $series = $a{series} || {};
40 my $w = $a{width} || 900;
41 my $h = $a{height} || 160;
42 my $link_file = $a{link_file} || '/file_changes.cgi';
43 my $link_schema = $a{link_schema} || '/schema_changes.cgi';
4144
4245 my $n = scalar @$days;
4346 return '' unless $n;
47 # Fall back to display labels for keys if caller didn't pass ISO dates.
48 my @keys = @$day_keys ? @$day_keys : @$days;
4449
4550 my @file_v = @{ $series->{file} || [] };
4651 my @schema_v = @{ $series->{schema} || [] };
4752
4853 my $max = 1;
4954 for my $i (0..$n-1) {
5055 my $t = ($file_v[$i] || 0) + ($schema_v[$i] || 0);
5156 $max = $t if $t > $max;
5257 }
5358
5459 my $pad_l = 40;
5560 my $pad_b = 22;
5661 my $pad_t = 8;
5762 my $pad_r = 8;
5863
5964 my $plot_w = $w - $pad_l - $pad_r;
6065 my $plot_h = $h - $pad_t - $pad_b;
6166
6267 my $bar_gap = 1;
6368 my $bar_w = ($plot_w - ($n - 1) * $bar_gap) / $n;
6469 $bar_w = 1 if $bar_w < 1;
6570
66 my $svg = qq~<svg viewBox="0 0 $w $h" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:$h" preserveAspectRatio="none">~;
71 my $svg = qq~<svg class="ds-timeline" viewBox="0 0 $w $h" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="width:100%;height:${h}px" preserveAspectRatio="none">~;
72 $svg .= q~<style>
73 .ds-timeline .ds-day { cursor: pointer; }
74 .ds-timeline .ds-day:hover .ds-hit { fill: rgba(20,184,166,0.06); }
75 .ds-timeline .ds-day:hover .ds-bar { opacity: 1; }
76 .ds-timeline .ds-bar { transition: opacity .15s ease; }
77 .ds-timeline a { text-decoration: none; }
78 </style>~;
6779
6880 # Grid lines + Y-axis labels
6981 for my $frac (0, 0.25, 0.5, 0.75, 1) {
7082 my $y = $pad_t + $plot_h * (1 - $frac);
7183 my $lbl = int($max * $frac);
7284 $svg .= qq~<line x1="$pad_l" y1="$y" x2="~ . ($w - $pad_r) . qq~" y2="$y" stroke="#223050" stroke-width="1" stroke-dasharray="2 4" opacity="0.5"/>~;
7385 $svg .= qq~<text x="~ . ($pad_l - 6) . qq~" y="$y" text-anchor="end" dominant-baseline="middle" font-family="var(--mono, monospace)" font-size="9" fill="#6a7a94">$lbl</text>~;
7486 }
7587
76 # Bars
88 # Bars -- wrap each day in a group with data-* attributes so a JS
89 # tooltip can pick up the counts on hover. Each segment is its own
90 # <a> so click routes to file_changes or schema_changes.
7791 for my $i (0..$n-1) {
7892 my $fv = $file_v[$i] || 0;
7993 my $sv = $schema_v[$i] || 0;
8094 my $x = $pad_l + $i * ($bar_w + $bar_gap);
8195 my $file_h = ($fv / $max) * $plot_h;
8296 my $schema_h = ($sv / $max) * $plot_h;
8397 my $file_y = $pad_t + $plot_h - $file_h;
8498 my $schema_y = $file_y - $schema_h;
99 my $key = _esc($keys[$i]);
100 my $label = _esc($days->[$i]);
85101
102 # Full-height invisible hit target for tooltip hover on empty days.
103 # Sits behind the visible bars.
104 $svg .= sprintf(
105 qq~<g class="ds-day" data-key="%s" data-label="%s" data-file="%d" data-schema="%d" data-link-file="%s?on=%s" data-link-schema="%s?on=%s">~,
106 $key, $label, $fv, $sv,
107 _esc($link_file), $key, _esc($link_schema), $key,
108 );
109 $svg .= sprintf(
110 qq~<rect class="ds-hit" x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="transparent"/>~,
111 $x, $pad_t, $bar_w, $plot_h,
112 );
86113 if ($fv > 0) {
87 $svg .= sprintf(qq~<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="#14b8a6" opacity="0.85"><title>%s: %d file changes</title></rect>~,
88 $x, $file_y, $bar_w, $file_h, _esc($days->[$i]), $fv);
114 $svg .= sprintf(
115 qq~<a xlink:href="%s?on=%s"><rect class="ds-bar ds-bar-file" x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="#14b8a6" opacity="0.85"/></a>~,
116 _esc($link_file), $key, $x, $file_y, $bar_w, $file_h,
117 );
89118 }
90119 if ($sv > 0) {
91 $svg .= sprintf(qq~<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="#f59e0b" opacity="0.85"><title>%s: %d schema changes</title></rect>~,
92 $x, $schema_y, $bar_w, $schema_h, _esc($days->[$i]), $sv);
120 $svg .= sprintf(
121 qq~<a xlink:href="%s?on=%s"><rect class="ds-bar ds-bar-schema" x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="#f59e0b" opacity="0.85"/></a>~,
122 _esc($link_schema), $key, $x, $schema_y, $bar_w, $schema_h,
123 );
93124 }
125 $svg .= qq~</g>~;
94126 }
95127
96128 # X-axis: label first, middle, last day
97129 my @tick_idx = (0, int($n/2), $n - 1);
98130 for my $i (@tick_idx) {
99131 my $x = $pad_l + $i * ($bar_w + $bar_gap) + ($bar_w / 2);
100132 my $y = $h - 6;
101133 my $lbl = _esc($days->[$i]);
102134 $svg .= qq~<text x="$x" y="$y" text-anchor="middle" font-family="var(--mono, monospace)" font-size="10" fill="#96a3b8">$lbl</text>~;
103135 }
104136
105137 # Legend
106138 my $legend_y = $pad_t + 8;
107139 my $legend_x = $w - $pad_r - 160;
108140 $svg .= qq~<g font-family="var(--sans, sans-serif)" font-size="10.5">~;
109141 $svg .= qq~<rect x="$legend_x" y="$legend_y" width="10" height="10" fill="#14b8a6"/>~;
110142 $svg .= qq~<text x="~ . ($legend_x + 14) . qq~" y="~ . ($legend_y + 9) . qq~" fill="#96a3b8">file changes</text>~;
111143 my $legend_x2 = $legend_x + 90;
112144 $svg .= qq~<rect x="$legend_x2" y="$legend_y" width="10" height="10" fill="#f59e0b"/>~;
113145 $svg .= qq~<text x="~ . ($legend_x2 + 14) . qq~" y="~ . ($legend_y + 9) . qq~" fill="#96a3b8">schema</text>~;
114146 $svg .= qq~</g>~;
115147
116148 $svg .= qq~</svg>~;
117149 return $svg;
118150}
119151
120152#---------------------------------------------------------------------
121153# sparkline({ values => \@nums, w, h, color })
122154#
123155# Tiny standalone SVG for inline use. Shows a 30-point (or however
124156# many values passed) mini line/bar chart. Great next to a file name
125157# or table name to convey "recent activity rhythm at a glance".
126158#---------------------------------------------------------------------
127159sub sparkline {
128160 my %a = @_;
129161 my $v = $a{values} || [];
130162 my $w = $a{width} || 80;
131163 my $h = $a{height} || 20;
132164 my $color = $a{color} || '#14b8a6';
133165
134166 my $n = scalar @$v;
135167 return '' unless $n;
136168
137169 my $max = 0;
138170 $max = $_ > $max ? $_ : $max for @$v;
139171 $max ||= 1;
140172
141173 my $gap = 1;
142174 my $bar_w = ($w - ($n - 1) * $gap) / $n;
143175 $bar_w = 1 if $bar_w < 1;
144176
145177 my $svg = qq~<svg viewBox="0 0 $w $h" xmlns="http://www.w3.org/2000/svg" style="display:inline-block;vertical-align:middle;width:${w}px;height:${h}px">~;
146178 for my $i (0..$n-1) {
147179 my $val = $v->[$i] || 0;
148180 next if $val == 0;
149181 my $bh = ($val / $max) * $h;
150182 my $x = $i * ($bar_w + $gap);
151183 my $y = $h - $bh;
152184 $svg .= sprintf(qq~<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" opacity="0.85"/>~,
153185 $x, $y, $bar_w, $bh, $color);
154186 }
155187 $svg .= qq~</svg>~;
156188 return $svg;
157189}
158190
159191#---------------------------------------------------------------------
160192# simple_donut({ size, values => [{v, color, label}], center_num, center_lbl })
161193#---------------------------------------------------------------------
162194sub simple_donut {
163195 my %a = @_;
164196 my $size = $a{size} || 140;
165197 my $thickness = $a{thickness} || 16;
166198 my $values = $a{values} || [];
167199 my $center_num = $a{center_num} // '';
168200 my $center_lbl = $a{center_lbl} // '';
169201
170202 my $cx = $size / 2; my $cy = $size / 2;
171203 my $r = ($size / 2) - ($thickness / 2) - 4;
172204 my $total = 0; $total += ($_->{v} || 0) for @$values;
173205 $total = 1 unless $total > 0;
174206
175207 my $svg = qq~<svg viewBox="0 0 $size $size" style="width:${size}px;height:${size}px">~;
176208 # Background ring
177209 $svg .= qq~<circle cx="$cx" cy="$cy" r="$r" stroke="#131e30" stroke-width="$thickness" fill="none"/>~;
178210
179211 my $start_deg = -90;
180212 foreach my $v (@$values) {
181213 my $angle = ($v->{v} / $total) * 360;
182214 next if $angle < 0.5;
183215 my $end_deg = $start_deg + $angle;
184216 my $large = ($angle > 180) ? 1 : 0;
185217 my ($x1, $y1) = _polar($cx, $cy, $r, $start_deg);
186218 my ($x2, $y2) = _polar($cx, $cy, $r, $end_deg);
187219 my $c = $v->{color} || '#14b8a6';
188220 $svg .= qq~<path d="M $x1 $y1 A $r $r 0 $large 1 $x2 $y2" stroke="$c" stroke-width="$thickness" fill="none" stroke-linecap="butt" opacity="0.95"/>~;
189221 $start_deg = $end_deg;
190222 }
191223
192224 my $cnum = _esc($center_num); my $clbl = _esc($center_lbl);
193225 $svg .= qq~<text x="$cx" y="$cy" text-anchor="middle" dy="4" font-family="var(--sans, sans-serif)" font-weight="800" font-size="20" fill="#e5edf5">$cnum</text>~;
194226 $svg .= qq~<text x="$cx" y="$cy" text-anchor="middle" dy="24" font-family="var(--sans, sans-serif)" font-size="10" letter-spacing="1.4" fill="#96a3b8">$clbl</text>~;
195227 $svg .= qq~</svg>~;
196228 return $svg;
197229}
198230
199231sub _polar {
200232 my ($cx, $cy, $r, $deg) = @_;
201233 my $rad = $deg * 3.14159265 / 180;
202234 return ($cx + $r * cos($rad), $cy + $r * sin($rad));
203235}
204236
2052371;