Diff -- /var/www/vhosts/3dshawn.com/site1/MODS/Charts.pm
Diff
/var/www/vhosts/3dshawn.com/site1/MODS/Charts.pm
modified on local at 2026-07-12 00:02:02
Added
+18
lines
Removed
-8
lines
Context
229
unchanged
Blobs
from 4b17b4998769
to 59483bc8e7f8
to 59483bc8e7f8
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | package MODS::Charts; |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # DriftSense -- hand-rolled SVG chart helpers (zero external deps). |
| 4 | 4 | # |
| 5 | 5 | # All charts are inline SVG strings that use CSS variables from |
| 6 | 6 | # drift_sense.css so palette + theme changes propagate automatically. |
| 7 | 7 | # |
| 8 | 8 | # Public functions (call as MODS::Charts::stacked_bars(...), etc.): |
| 9 | 9 | # |
| 10 | 10 | # stacked_bars(days => \@days, series => { file => \@nums, schema => \@nums }) |
| 11 | 11 | # Wide activity timeline. One bar per day, stacked by series. |
| 12 | 12 | # |
| 13 | 13 | # sparkline(values => \@nums, w => 80, h => 20) |
| 14 | 14 | # Tiny inline SVG summary line. Great next to file / table names. |
| 15 | 15 | # |
| 16 | 16 | # simple_donut(size, values => [{v, color, label}], center) |
| 17 | 17 | # Compact donut for savings / drift / dedup ratios. |
| 18 | 18 | #====================================================================== |
| 19 | 19 | use strict; |
| 20 | 20 | use warnings; |
| 21 | 21 | |
| 22 | 22 | sub _esc { |
| 23 | 23 | my $s = shift; $s //= ''; |
| 24 | 24 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; |
| 25 | 25 | return $s; |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | #--------------------------------------------------------------------- |
| 29 | 29 | # stacked_bars({ days => [...], series => { file => [...], schema => [...] } }) |
| 30 | 30 | # |
| 31 | 31 | # @days = arrayref of day labels (oldest -> newest). @series entries |
| 32 | 32 | # must be same-length arrayrefs of integers. Bars stack file (teal) |
| 33 | 33 | # below schema (amber). Y-axis auto-scales. |
| 34 | 34 | #--------------------------------------------------------------------- |
| 35 | 35 | sub stacked_bars { |
| 36 | 36 | my %a = @_; |
| 37 | 37 | my $days = $a{days} || []; # display labels e.g. "Jul 10" |
| 38 | 38 | my $day_keys = $a{day_keys} || []; # ISO YYYY-MM-DD for links/tooltips |
| 39 | 39 | my $series = $a{series} || {}; |
| 40 | 40 | my $w = $a{width} || 900; |
| 41 | 41 | my $h = $a{height} || 160; |
| 42 | 42 | my $link_file = $a{link_file} || '/file_changes.cgi'; |
| 43 | 43 | my $link_schema = $a{link_schema} || '/schema_changes.cgi'; |
| 44 | 44 | |
| 45 | 45 | my $n = scalar @$days; |
| 46 | 46 | return '' unless $n; |
| 47 | 47 | # Fall back to display labels for keys if caller didn't pass ISO dates. |
| 48 | 48 | my @keys = @$day_keys ? @$day_keys : @$days; |
| 49 | 49 | |
| 50 | 50 | my @file_v = @{ $series->{file} || [] }; |
| 51 | 51 | my @schema_v = @{ $series->{schema} || [] }; |
| 52 | 52 | |
| 53 | 53 | my $max = 1; |
| 54 | 54 | for my $i (0..$n-1) { |
| 55 | 55 | my $t = ($file_v[$i] || 0) + ($schema_v[$i] || 0); |
| 56 | 56 | $max = $t if $t > $max; |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | my $pad_l = 40; |
| 60 | 60 | my $pad_b = 22; |
| 61 | 61 | my $pad_t = 8; |
| 62 | 62 | my $pad_r = 8; |
| 63 | 63 | |
| 64 | 64 | my $plot_w = $w - $pad_l - $pad_r; |
| 65 | 65 | my $plot_h = $h - $pad_t - $pad_b; |
| 66 | 66 | |
| 67 | 67 | my $bar_gap = 1; |
| 68 | 68 | my $bar_w = ($plot_w - ($n - 1) * $bar_gap) / $n; |
| 69 | 69 | $bar_w = 1 if $bar_w < 1; |
| 70 | 70 | |
| 71 | 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 | 72 | $svg .= q~<style> |
| 73 | 73 | .ds-timeline .ds-day { cursor: pointer; } |
| 74 | 74 | .ds-timeline .ds-day:hover .ds-hit { fill: rgba(20,184,166,0.06); } |
| 75 | 75 | .ds-timeline .ds-day:hover .ds-bar { opacity: 1; } |
| 76 | 76 | .ds-timeline .ds-bar { transition: opacity .15s ease; } |
| 77 | 77 | .ds-timeline a { text-decoration: none; } |
| 78 | 78 | </style>~; |
| 79 | 79 | |
| 80 | 80 | # Grid lines + Y-axis labels |
| 81 | 81 | for my $frac (0, 0.25, 0.5, 0.75, 1) { |
| 82 | 82 | my $y = $pad_t + $plot_h * (1 - $frac); |
| 83 | 83 | my $lbl = int($max * $frac); |
| 84 | 84 | $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"/>~; |
| 85 | 85 | $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>~; |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | 88 | # Bars -- wrap each day in a group with data-* attributes so a JS |
| 89 | 89 | # tooltip can pick up the counts on hover. Each segment is its own |
| 90 | 90 | # <a> so click routes to file_changes or schema_changes. |
| 91 | 91 | for my $i (0..$n-1) { |
| 92 | 92 | my $fv = $file_v[$i] || 0; |
| 93 | 93 | my $sv = $schema_v[$i] || 0; |
| 94 | 94 | my $x = $pad_l + $i * ($bar_w + $bar_gap); |
| 95 | 95 | my $file_h = ($fv / $max) * $plot_h; |
| 96 | 96 | my $schema_h = ($sv / $max) * $plot_h; |
| 97 | 97 | my $file_y = $pad_t + $plot_h - $file_h; |
| 98 | 98 | my $schema_y = $file_y - $schema_h; |
| 99 | 99 | my $key = _esc($keys[$i]); |
| 100 | 100 | my $label = _esc($days->[$i]); |
| 101 | 101 | |
| 102 | 102 | # Full-height invisible hit target for tooltip hover on empty days. |
| 103 | 103 | # Sits behind the visible bars. |
| 104 | 104 | $svg .= sprintf( |
| 105 | 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 | 106 | $key, $label, $fv, $sv, |
| 107 | 107 | _esc($link_file), $key, _esc($link_schema), $key, |
| 108 | 108 | ); |
| 109 | 109 | $svg .= sprintf( |
| 110 | 110 | qq~<rect class="ds-hit" x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="transparent"/>~, |
| 111 | 111 | $x, $pad_t, $bar_w, $plot_h, |
| 112 | 112 | ); |
| 113 | 113 | if ($fv > 0) { |
| 114 | 114 | $svg .= sprintf( |
| 115 | 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 | 116 | _esc($link_file), $key, $x, $file_y, $bar_w, $file_h, |
| 117 | 117 | ); |
| 118 | 118 | } |
| 119 | 119 | if ($sv > 0) { |
| 120 | 120 | $svg .= sprintf( |
| 121 | 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 | 122 | _esc($link_schema), $key, $x, $schema_y, $bar_w, $schema_h, |
| 123 | 123 | ); |
| 124 | 124 | } |
| 125 | 125 | $svg .= qq~</g>~; |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | 128 | # X-axis: label first, middle, last day |
| 129 | 129 | my @tick_idx = (0, int($n/2), $n - 1); |
| 130 | 130 | for my $i (@tick_idx) { |
| 131 | 131 | my $x = $pad_l + $i * ($bar_w + $bar_gap) + ($bar_w / 2); |
| 132 | 132 | my $y = $h - 6; |
| 133 | 133 | my $lbl = _esc($days->[$i]); |
| 134 | 134 | $svg .= qq~<text x="$x" y="$y" text-anchor="middle" font-family="var(--mono, monospace)" font-size="10" fill="#96a3b8">$lbl</text>~; |
| 135 | 135 | } |
| 136 | 136 | |
| 137 | # Legend | |
| 138 | my $legend_y = $pad_t + 8; | |
| 139 | my $legend_x = $w - $pad_r - 160; | |
| 137 | # Legend -- drawn LAST with a solid background rect so bars from | |
| 138 | # busy days behind it can't visually intrude. | |
| 139 | my $legend_y = $pad_t + 4; | |
| 140 | my $legend_w = 172; | |
| 141 | my $legend_h = 20; | |
| 142 | my $legend_x = $w - $pad_r - $legend_w; | |
| 143 | # Backdrop matches the module body bg + a subtle border for polish | |
| 144 | $svg .= sprintf( | |
| 145 | qq~<rect x="%.2f" y="%.2f" width="%d" height="%d" fill="#131e30" stroke="#223050" stroke-width="1" rx="6"/>~, | |
| 146 | $legend_x, $legend_y, $legend_w, $legend_h, | |
| 147 | ); | |
| 148 | my $lx = $legend_x + 10; | |
| 149 | my $ly = $legend_y + 5; | |
| 140 | 150 | $svg .= qq~<g font-family="var(--sans, sans-serif)" font-size="10.5">~; |
| 141 | $svg .= qq~<rect x="$legend_x" y="$legend_y" width="10" height="10" fill="#14b8a6"/>~; | |
| 142 | $svg .= qq~<text x="~ . ($legend_x + 14) . qq~" y="~ . ($legend_y + 9) . qq~" fill="#96a3b8">file changes</text>~; | |
| 143 | my $legend_x2 = $legend_x + 90; | |
| 144 | $svg .= qq~<rect x="$legend_x2" y="$legend_y" width="10" height="10" fill="#f59e0b"/>~; | |
| 145 | $svg .= qq~<text x="~ . ($legend_x2 + 14) . qq~" y="~ . ($legend_y + 9) . qq~" fill="#96a3b8">schema</text>~; | |
| 151 | $svg .= qq~<rect x="$lx" y="$ly" width="10" height="10" fill="#14b8a6" rx="1"/>~; | |
| 152 | $svg .= qq~<text x="~ . ($lx + 14) . qq~" y="~ . ($ly + 9) . qq~" fill="#96a3b8">file changes</text>~; | |
| 153 | my $lx2 = $lx + 88; | |
| 154 | $svg .= qq~<rect x="$lx2" y="$ly" width="10" height="10" fill="#f59e0b" rx="1"/>~; | |
| 155 | $svg .= qq~<text x="~ . ($lx2 + 14) . qq~" y="~ . ($ly + 9) . qq~" fill="#96a3b8">schema</text>~; | |
| 146 | 156 | $svg .= qq~</g>~; |
| 147 | 157 | |
| 148 | 158 | $svg .= qq~</svg>~; |
| 149 | 159 | return $svg; |
| 150 | 160 | } |
| 151 | 161 | |
| 152 | 162 | #--------------------------------------------------------------------- |
| 153 | 163 | # sparkline({ values => \@nums, w, h, color }) |
| 154 | 164 | # |
| 155 | 165 | # Tiny standalone SVG for inline use. Shows a 30-point (or however |
| 156 | 166 | # many values passed) mini line/bar chart. Great next to a file name |
| 157 | 167 | # or table name to convey "recent activity rhythm at a glance". |
| 158 | 168 | #--------------------------------------------------------------------- |
| 159 | 169 | sub sparkline { |
| 160 | 170 | my %a = @_; |
| 161 | 171 | my $v = $a{values} || []; |
| 162 | 172 | my $w = $a{width} || 80; |
| 163 | 173 | my $h = $a{height} || 20; |
| 164 | 174 | my $color = $a{color} || '#14b8a6'; |
| 165 | 175 | |
| 166 | 176 | my $n = scalar @$v; |
| 167 | 177 | return '' unless $n; |
| 168 | 178 | |
| 169 | 179 | my $max = 0; |
| 170 | 180 | $max = $_ > $max ? $_ : $max for @$v; |
| 171 | 181 | $max ||= 1; |
| 172 | 182 | |
| 173 | 183 | my $gap = 1; |
| 174 | 184 | my $bar_w = ($w - ($n - 1) * $gap) / $n; |
| 175 | 185 | $bar_w = 1 if $bar_w < 1; |
| 176 | 186 | |
| 177 | 187 | 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">~; |
| 178 | 188 | for my $i (0..$n-1) { |
| 179 | 189 | my $val = $v->[$i] || 0; |
| 180 | 190 | next if $val == 0; |
| 181 | 191 | my $bh = ($val / $max) * $h; |
| 182 | 192 | my $x = $i * ($bar_w + $gap); |
| 183 | 193 | my $y = $h - $bh; |
| 184 | 194 | $svg .= sprintf(qq~<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" opacity="0.85"/>~, |
| 185 | 195 | $x, $y, $bar_w, $bh, $color); |
| 186 | 196 | } |
| 187 | 197 | $svg .= qq~</svg>~; |
| 188 | 198 | return $svg; |
| 189 | 199 | } |
| 190 | 200 | |
| 191 | 201 | #--------------------------------------------------------------------- |
| 192 | 202 | # simple_donut({ size, values => [{v, color, label}], center_num, center_lbl }) |
| 193 | 203 | #--------------------------------------------------------------------- |
| 194 | 204 | sub simple_donut { |
| 195 | 205 | my %a = @_; |
| 196 | 206 | my $size = $a{size} || 140; |
| 197 | 207 | my $thickness = $a{thickness} || 16; |
| 198 | 208 | my $values = $a{values} || []; |
| 199 | 209 | my $center_num = $a{center_num} // ''; |
| 200 | 210 | my $center_lbl = $a{center_lbl} // ''; |
| 201 | 211 | |
| 202 | 212 | my $cx = $size / 2; my $cy = $size / 2; |
| 203 | 213 | my $r = ($size / 2) - ($thickness / 2) - 4; |
| 204 | 214 | my $total = 0; $total += ($_->{v} || 0) for @$values; |
| 205 | 215 | $total = 1 unless $total > 0; |
| 206 | 216 | |
| 207 | 217 | my $svg = qq~<svg viewBox="0 0 $size $size" style="width:${size}px;height:${size}px">~; |
| 208 | 218 | # Background ring |
| 209 | 219 | $svg .= qq~<circle cx="$cx" cy="$cy" r="$r" stroke="#131e30" stroke-width="$thickness" fill="none"/>~; |
| 210 | 220 | |
| 211 | 221 | my $start_deg = -90; |
| 212 | 222 | foreach my $v (@$values) { |
| 213 | 223 | my $angle = ($v->{v} / $total) * 360; |
| 214 | 224 | next if $angle < 0.5; |
| 215 | 225 | my $end_deg = $start_deg + $angle; |
| 216 | 226 | my $large = ($angle > 180) ? 1 : 0; |
| 217 | 227 | my ($x1, $y1) = _polar($cx, $cy, $r, $start_deg); |
| 218 | 228 | my ($x2, $y2) = _polar($cx, $cy, $r, $end_deg); |
| 219 | 229 | my $c = $v->{color} || '#14b8a6'; |
| 220 | 230 | $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"/>~; |
| 221 | 231 | $start_deg = $end_deg; |
| 222 | 232 | } |
| 223 | 233 | |
| 224 | 234 | my $cnum = _esc($center_num); my $clbl = _esc($center_lbl); |
| 225 | 235 | $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>~; |
| 226 | 236 | $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>~; |
| 227 | 237 | $svg .= qq~</svg>~; |
| 228 | 238 | return $svg; |
| 229 | 239 | } |
| 230 | 240 | |
| 231 | 241 | sub _polar { |
| 232 | 242 | my ($cx, $cy, $r, $deg) = @_; |
| 233 | 243 | my $rad = $deg * 3.14159265 / 180; |
| 234 | 244 | return ($cx + $r * cos($rad), $cy + $r * sin($rad)); |
| 235 | 245 | } |
| 236 | 246 | |
| 237 | 247 | 1; |