Diff -- /var/www/vhosts/3dshawn.com/site1/MODS/Charts.pm
Diff
/var/www/vhosts/3dshawn.com/site1/MODS/Charts.pm
added on local at 2026-07-10 22:19:04
Added
+205
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 73cae7c6d547
to 73cae7c6d547
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | package MODS::Charts; | |
| 2 | #====================================================================== | |
| 3 | # DriftSense -- hand-rolled SVG chart helpers (zero external deps). | |
| 4 | # | |
| 5 | # All charts are inline SVG strings that use CSS variables from | |
| 6 | # drift_sense.css so palette + theme changes propagate automatically. | |
| 7 | # | |
| 8 | # Public functions (call as MODS::Charts::stacked_bars(...), etc.): | |
| 9 | # | |
| 10 | # stacked_bars(days => \@days, series => { file => \@nums, schema => \@nums }) | |
| 11 | # Wide activity timeline. One bar per day, stacked by series. | |
| 12 | # | |
| 13 | # sparkline(values => \@nums, w => 80, h => 20) | |
| 14 | # Tiny inline SVG summary line. Great next to file / table names. | |
| 15 | # | |
| 16 | # simple_donut(size, values => [{v, color, label}], center) | |
| 17 | # Compact donut for savings / drift / dedup ratios. | |
| 18 | #====================================================================== | |
| 19 | use strict; | |
| 20 | use warnings; | |
| 21 | ||
| 22 | sub _esc { | |
| 23 | my $s = shift; $s //= ''; | |
| 24 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 25 | return $s; | |
| 26 | } | |
| 27 | ||
| 28 | #--------------------------------------------------------------------- | |
| 29 | # stacked_bars({ days => [...], series => { file => [...], schema => [...] } }) | |
| 30 | # | |
| 31 | # @days = arrayref of day labels (oldest -> newest). @series entries | |
| 32 | # must be same-length arrayrefs of integers. Bars stack file (teal) | |
| 33 | # below schema (amber). Y-axis auto-scales. | |
| 34 | #--------------------------------------------------------------------- | |
| 35 | sub stacked_bars { | |
| 36 | my %a = @_; | |
| 37 | my $days = $a{days} || []; | |
| 38 | my $series = $a{series} || {}; | |
| 39 | my $w = $a{width} || 900; | |
| 40 | my $h = $a{height} || 160; | |
| 41 | ||
| 42 | my $n = scalar @$days; | |
| 43 | return '' unless $n; | |
| 44 | ||
| 45 | my @file_v = @{ $series->{file} || [] }; | |
| 46 | my @schema_v = @{ $series->{schema} || [] }; | |
| 47 | ||
| 48 | my $max = 1; | |
| 49 | for my $i (0..$n-1) { | |
| 50 | my $t = ($file_v[$i] || 0) + ($schema_v[$i] || 0); | |
| 51 | $max = $t if $t > $max; | |
| 52 | } | |
| 53 | ||
| 54 | my $pad_l = 40; | |
| 55 | my $pad_b = 22; | |
| 56 | my $pad_t = 8; | |
| 57 | my $pad_r = 8; | |
| 58 | ||
| 59 | my $plot_w = $w - $pad_l - $pad_r; | |
| 60 | my $plot_h = $h - $pad_t - $pad_b; | |
| 61 | ||
| 62 | my $bar_gap = 1; | |
| 63 | my $bar_w = ($plot_w - ($n - 1) * $bar_gap) / $n; | |
| 64 | $bar_w = 1 if $bar_w < 1; | |
| 65 | ||
| 66 | my $svg = qq~<svg viewBox="0 0 $w $h" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:$h" preserveAspectRatio="none">~; | |
| 67 | ||
| 68 | # Grid lines + Y-axis labels | |
| 69 | for my $frac (0, 0.25, 0.5, 0.75, 1) { | |
| 70 | my $y = $pad_t + $plot_h * (1 - $frac); | |
| 71 | my $lbl = int($max * $frac); | |
| 72 | $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"/>~; | |
| 73 | $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>~; | |
| 74 | } | |
| 75 | ||
| 76 | # Bars | |
| 77 | for my $i (0..$n-1) { | |
| 78 | my $fv = $file_v[$i] || 0; | |
| 79 | my $sv = $schema_v[$i] || 0; | |
| 80 | my $x = $pad_l + $i * ($bar_w + $bar_gap); | |
| 81 | my $file_h = ($fv / $max) * $plot_h; | |
| 82 | my $schema_h = ($sv / $max) * $plot_h; | |
| 83 | my $file_y = $pad_t + $plot_h - $file_h; | |
| 84 | my $schema_y = $file_y - $schema_h; | |
| 85 | ||
| 86 | 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); | |
| 89 | } | |
| 90 | 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); | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | # X-axis: label first, middle, last day | |
| 97 | my @tick_idx = (0, int($n/2), $n - 1); | |
| 98 | for my $i (@tick_idx) { | |
| 99 | my $x = $pad_l + $i * ($bar_w + $bar_gap) + ($bar_w / 2); | |
| 100 | my $y = $h - 6; | |
| 101 | my $lbl = _esc($days->[$i]); | |
| 102 | $svg .= qq~<text x="$x" y="$y" text-anchor="middle" font-family="var(--mono, monospace)" font-size="10" fill="#96a3b8">$lbl</text>~; | |
| 103 | } | |
| 104 | ||
| 105 | # Legend | |
| 106 | my $legend_y = $pad_t + 8; | |
| 107 | my $legend_x = $w - $pad_r - 160; | |
| 108 | $svg .= qq~<g font-family="var(--sans, sans-serif)" font-size="10.5">~; | |
| 109 | $svg .= qq~<rect x="$legend_x" y="$legend_y" width="10" height="10" fill="#14b8a6"/>~; | |
| 110 | $svg .= qq~<text x="~ . ($legend_x + 14) . qq~" y="~ . ($legend_y + 9) . qq~" fill="#96a3b8">file changes</text>~; | |
| 111 | my $legend_x2 = $legend_x + 90; | |
| 112 | $svg .= qq~<rect x="$legend_x2" y="$legend_y" width="10" height="10" fill="#f59e0b"/>~; | |
| 113 | $svg .= qq~<text x="~ . ($legend_x2 + 14) . qq~" y="~ . ($legend_y + 9) . qq~" fill="#96a3b8">schema</text>~; | |
| 114 | $svg .= qq~</g>~; | |
| 115 | ||
| 116 | $svg .= qq~</svg>~; | |
| 117 | return $svg; | |
| 118 | } | |
| 119 | ||
| 120 | #--------------------------------------------------------------------- | |
| 121 | # sparkline({ values => \@nums, w, h, color }) | |
| 122 | # | |
| 123 | # Tiny standalone SVG for inline use. Shows a 30-point (or however | |
| 124 | # many values passed) mini line/bar chart. Great next to a file name | |
| 125 | # or table name to convey "recent activity rhythm at a glance". | |
| 126 | #--------------------------------------------------------------------- | |
| 127 | sub sparkline { | |
| 128 | my %a = @_; | |
| 129 | my $v = $a{values} || []; | |
| 130 | my $w = $a{width} || 80; | |
| 131 | my $h = $a{height} || 20; | |
| 132 | my $color = $a{color} || '#14b8a6'; | |
| 133 | ||
| 134 | my $n = scalar @$v; | |
| 135 | return '' unless $n; | |
| 136 | ||
| 137 | my $max = 0; | |
| 138 | $max = $_ > $max ? $_ : $max for @$v; | |
| 139 | $max ||= 1; | |
| 140 | ||
| 141 | my $gap = 1; | |
| 142 | my $bar_w = ($w - ($n - 1) * $gap) / $n; | |
| 143 | $bar_w = 1 if $bar_w < 1; | |
| 144 | ||
| 145 | 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">~; | |
| 146 | for my $i (0..$n-1) { | |
| 147 | my $val = $v->[$i] || 0; | |
| 148 | next if $val == 0; | |
| 149 | my $bh = ($val / $max) * $h; | |
| 150 | my $x = $i * ($bar_w + $gap); | |
| 151 | my $y = $h - $bh; | |
| 152 | $svg .= sprintf(qq~<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" opacity="0.85"/>~, | |
| 153 | $x, $y, $bar_w, $bh, $color); | |
| 154 | } | |
| 155 | $svg .= qq~</svg>~; | |
| 156 | return $svg; | |
| 157 | } | |
| 158 | ||
| 159 | #--------------------------------------------------------------------- | |
| 160 | # simple_donut({ size, values => [{v, color, label}], center_num, center_lbl }) | |
| 161 | #--------------------------------------------------------------------- | |
| 162 | sub simple_donut { | |
| 163 | my %a = @_; | |
| 164 | my $size = $a{size} || 140; | |
| 165 | my $thickness = $a{thickness} || 16; | |
| 166 | my $values = $a{values} || []; | |
| 167 | my $center_num = $a{center_num} // ''; | |
| 168 | my $center_lbl = $a{center_lbl} // ''; | |
| 169 | ||
| 170 | my $cx = $size / 2; my $cy = $size / 2; | |
| 171 | my $r = ($size / 2) - ($thickness / 2) - 4; | |
| 172 | my $total = 0; $total += ($_->{v} || 0) for @$values; | |
| 173 | $total = 1 unless $total > 0; | |
| 174 | ||
| 175 | my $svg = qq~<svg viewBox="0 0 $size $size" style="width:${size}px;height:${size}px">~; | |
| 176 | # Background ring | |
| 177 | $svg .= qq~<circle cx="$cx" cy="$cy" r="$r" stroke="#131e30" stroke-width="$thickness" fill="none"/>~; | |
| 178 | ||
| 179 | my $start_deg = -90; | |
| 180 | foreach my $v (@$values) { | |
| 181 | my $angle = ($v->{v} / $total) * 360; | |
| 182 | next if $angle < 0.5; | |
| 183 | my $end_deg = $start_deg + $angle; | |
| 184 | my $large = ($angle > 180) ? 1 : 0; | |
| 185 | my ($x1, $y1) = _polar($cx, $cy, $r, $start_deg); | |
| 186 | my ($x2, $y2) = _polar($cx, $cy, $r, $end_deg); | |
| 187 | my $c = $v->{color} || '#14b8a6'; | |
| 188 | $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"/>~; | |
| 189 | $start_deg = $end_deg; | |
| 190 | } | |
| 191 | ||
| 192 | my $cnum = _esc($center_num); my $clbl = _esc($center_lbl); | |
| 193 | $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>~; | |
| 194 | $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>~; | |
| 195 | $svg .= qq~</svg>~; | |
| 196 | return $svg; | |
| 197 | } | |
| 198 | ||
| 199 | sub _polar { | |
| 200 | my ($cx, $cy, $r, $deg) = @_; | |
| 201 | my $rad = $deg * 3.14159265 / 180; | |
| 202 | return ($cx + $r * cos($rad), $cy + $r * sin($rad)); | |
| 203 | } | |
| 204 | ||
| 205 | 1; |