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

O Operator
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
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package 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#======================================================================
19use strict;
20use warnings;
21
22sub _esc {
23 my $s = shift; $s //= '';
24 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/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#---------------------------------------------------------------------
35sub 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#---------------------------------------------------------------------
127sub 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#---------------------------------------------------------------------
162sub 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
199sub _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
2051;