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-10 23:21:50
Added
+42
lines
Removed
-10
lines
Context
195
unchanged
Blobs
from 73cae7c6d547
to 4b17b4998769
to 4b17b4998769
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 | 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'; | |
| 41 | 44 | |
| 42 | 45 | my $n = scalar @$days; |
| 43 | 46 | 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; | |
| 44 | 49 | |
| 45 | 50 | my @file_v = @{ $series->{file} || [] }; |
| 46 | 51 | my @schema_v = @{ $series->{schema} || [] }; |
| 47 | 52 | |
| 48 | 53 | my $max = 1; |
| 49 | 54 | for my $i (0..$n-1) { |
| 50 | 55 | my $t = ($file_v[$i] || 0) + ($schema_v[$i] || 0); |
| 51 | 56 | $max = $t if $t > $max; |
| 52 | 57 | } |
| 53 | 58 | |
| 54 | 59 | my $pad_l = 40; |
| 55 | 60 | my $pad_b = 22; |
| 56 | 61 | my $pad_t = 8; |
| 57 | 62 | my $pad_r = 8; |
| 58 | 63 | |
| 59 | 64 | my $plot_w = $w - $pad_l - $pad_r; |
| 60 | 65 | my $plot_h = $h - $pad_t - $pad_b; |
| 61 | 66 | |
| 62 | 67 | my $bar_gap = 1; |
| 63 | 68 | my $bar_w = ($plot_w - ($n - 1) * $bar_gap) / $n; |
| 64 | 69 | $bar_w = 1 if $bar_w < 1; |
| 65 | 70 | |
| 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>~; | |
| 67 | 79 | |
| 68 | 80 | # Grid lines + Y-axis labels |
| 69 | 81 | for my $frac (0, 0.25, 0.5, 0.75, 1) { |
| 70 | 82 | my $y = $pad_t + $plot_h * (1 - $frac); |
| 71 | 83 | my $lbl = int($max * $frac); |
| 72 | 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"/>~; |
| 73 | 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>~; |
| 74 | 86 | } |
| 75 | 87 | |
| 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. | |
| 77 | 91 | for my $i (0..$n-1) { |
| 78 | 92 | my $fv = $file_v[$i] || 0; |
| 79 | 93 | my $sv = $schema_v[$i] || 0; |
| 80 | 94 | my $x = $pad_l + $i * ($bar_w + $bar_gap); |
| 81 | 95 | my $file_h = ($fv / $max) * $plot_h; |
| 82 | 96 | my $schema_h = ($sv / $max) * $plot_h; |
| 83 | 97 | my $file_y = $pad_t + $plot_h - $file_h; |
| 84 | 98 | my $schema_y = $file_y - $schema_h; |
| 99 | my $key = _esc($keys[$i]); | |
| 100 | my $label = _esc($days->[$i]); | |
| 85 | 101 | |
| 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 | ); | |
| 86 | 113 | 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 | ); | |
| 89 | 118 | } |
| 90 | 119 | 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 | ); | |
| 93 | 124 | } |
| 125 | $svg .= qq~</g>~; | |
| 94 | 126 | } |
| 95 | 127 | |
| 96 | 128 | # X-axis: label first, middle, last day |
| 97 | 129 | my @tick_idx = (0, int($n/2), $n - 1); |
| 98 | 130 | for my $i (@tick_idx) { |
| 99 | 131 | my $x = $pad_l + $i * ($bar_w + $bar_gap) + ($bar_w / 2); |
| 100 | 132 | my $y = $h - 6; |
| 101 | 133 | my $lbl = _esc($days->[$i]); |
| 102 | 134 | $svg .= qq~<text x="$x" y="$y" text-anchor="middle" font-family="var(--mono, monospace)" font-size="10" fill="#96a3b8">$lbl</text>~; |
| 103 | 135 | } |
| 104 | 136 | |
| 105 | 137 | # Legend |
| 106 | 138 | my $legend_y = $pad_t + 8; |
| 107 | 139 | my $legend_x = $w - $pad_r - 160; |
| 108 | 140 | $svg .= qq~<g font-family="var(--sans, sans-serif)" font-size="10.5">~; |
| 109 | 141 | $svg .= qq~<rect x="$legend_x" y="$legend_y" width="10" height="10" fill="#14b8a6"/>~; |
| 110 | 142 | $svg .= qq~<text x="~ . ($legend_x + 14) . qq~" y="~ . ($legend_y + 9) . qq~" fill="#96a3b8">file changes</text>~; |
| 111 | 143 | my $legend_x2 = $legend_x + 90; |
| 112 | 144 | $svg .= qq~<rect x="$legend_x2" y="$legend_y" width="10" height="10" fill="#f59e0b"/>~; |
| 113 | 145 | $svg .= qq~<text x="~ . ($legend_x2 + 14) . qq~" y="~ . ($legend_y + 9) . qq~" fill="#96a3b8">schema</text>~; |
| 114 | 146 | $svg .= qq~</g>~; |
| 115 | 147 | |
| 116 | 148 | $svg .= qq~</svg>~; |
| 117 | 149 | return $svg; |
| 118 | 150 | } |
| 119 | 151 | |
| 120 | 152 | #--------------------------------------------------------------------- |
| 121 | 153 | # sparkline({ values => \@nums, w, h, color }) |
| 122 | 154 | # |
| 123 | 155 | # Tiny standalone SVG for inline use. Shows a 30-point (or however |
| 124 | 156 | # many values passed) mini line/bar chart. Great next to a file name |
| 125 | 157 | # or table name to convey "recent activity rhythm at a glance". |
| 126 | 158 | #--------------------------------------------------------------------- |
| 127 | 159 | sub sparkline { |
| 128 | 160 | my %a = @_; |
| 129 | 161 | my $v = $a{values} || []; |
| 130 | 162 | my $w = $a{width} || 80; |
| 131 | 163 | my $h = $a{height} || 20; |
| 132 | 164 | my $color = $a{color} || '#14b8a6'; |
| 133 | 165 | |
| 134 | 166 | my $n = scalar @$v; |
| 135 | 167 | return '' unless $n; |
| 136 | 168 | |
| 137 | 169 | my $max = 0; |
| 138 | 170 | $max = $_ > $max ? $_ : $max for @$v; |
| 139 | 171 | $max ||= 1; |
| 140 | 172 | |
| 141 | 173 | my $gap = 1; |
| 142 | 174 | my $bar_w = ($w - ($n - 1) * $gap) / $n; |
| 143 | 175 | $bar_w = 1 if $bar_w < 1; |
| 144 | 176 | |
| 145 | 177 | 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 | 178 | for my $i (0..$n-1) { |
| 147 | 179 | my $val = $v->[$i] || 0; |
| 148 | 180 | next if $val == 0; |
| 149 | 181 | my $bh = ($val / $max) * $h; |
| 150 | 182 | my $x = $i * ($bar_w + $gap); |
| 151 | 183 | my $y = $h - $bh; |
| 152 | 184 | $svg .= sprintf(qq~<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" opacity="0.85"/>~, |
| 153 | 185 | $x, $y, $bar_w, $bh, $color); |
| 154 | 186 | } |
| 155 | 187 | $svg .= qq~</svg>~; |
| 156 | 188 | return $svg; |
| 157 | 189 | } |
| 158 | 190 | |
| 159 | 191 | #--------------------------------------------------------------------- |
| 160 | 192 | # simple_donut({ size, values => [{v, color, label}], center_num, center_lbl }) |
| 161 | 193 | #--------------------------------------------------------------------- |
| 162 | 194 | sub simple_donut { |
| 163 | 195 | my %a = @_; |
| 164 | 196 | my $size = $a{size} || 140; |
| 165 | 197 | my $thickness = $a{thickness} || 16; |
| 166 | 198 | my $values = $a{values} || []; |
| 167 | 199 | my $center_num = $a{center_num} // ''; |
| 168 | 200 | my $center_lbl = $a{center_lbl} // ''; |
| 169 | 201 | |
| 170 | 202 | my $cx = $size / 2; my $cy = $size / 2; |
| 171 | 203 | my $r = ($size / 2) - ($thickness / 2) - 4; |
| 172 | 204 | my $total = 0; $total += ($_->{v} || 0) for @$values; |
| 173 | 205 | $total = 1 unless $total > 0; |
| 174 | 206 | |
| 175 | 207 | my $svg = qq~<svg viewBox="0 0 $size $size" style="width:${size}px;height:${size}px">~; |
| 176 | 208 | # Background ring |
| 177 | 209 | $svg .= qq~<circle cx="$cx" cy="$cy" r="$r" stroke="#131e30" stroke-width="$thickness" fill="none"/>~; |
| 178 | 210 | |
| 179 | 211 | my $start_deg = -90; |
| 180 | 212 | foreach my $v (@$values) { |
| 181 | 213 | my $angle = ($v->{v} / $total) * 360; |
| 182 | 214 | next if $angle < 0.5; |
| 183 | 215 | my $end_deg = $start_deg + $angle; |
| 184 | 216 | my $large = ($angle > 180) ? 1 : 0; |
| 185 | 217 | my ($x1, $y1) = _polar($cx, $cy, $r, $start_deg); |
| 186 | 218 | my ($x2, $y2) = _polar($cx, $cy, $r, $end_deg); |
| 187 | 219 | my $c = $v->{color} || '#14b8a6'; |
| 188 | 220 | $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 | 221 | $start_deg = $end_deg; |
| 190 | 222 | } |
| 191 | 223 | |
| 192 | 224 | my $cnum = _esc($center_num); my $clbl = _esc($center_lbl); |
| 193 | 225 | $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 | 226 | $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 | 227 | $svg .= qq~</svg>~; |
| 196 | 228 | return $svg; |
| 197 | 229 | } |
| 198 | 230 | |
| 199 | 231 | sub _polar { |
| 200 | 232 | my ($cx, $cy, $r, $deg) = @_; |
| 201 | 233 | my $rad = $deg * 3.14159265 / 180; |
| 202 | 234 | return ($cx + $r * cos($rad), $cy + $r * sin($rad)); |
| 203 | 235 | } |
| 204 | 236 | |
| 205 | 237 | 1; |