diff options
Diffstat (limited to 'src/lib/stats.source.sh')
| -rw-r--r-- | src/lib/stats.source.sh | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/src/lib/stats.source.sh b/src/lib/stats.source.sh index cdb8668..f4f2e1f 100644 --- a/src/lib/stats.source.sh +++ b/src/lib/stats.source.sh @@ -548,3 +548,299 @@ collect_photo_exif_stats() { < <(cached_photo_identify_output "$photo" "$INCOMING_DIR/$photo") done < <(incoming_image_files) } + +# ---------------------------------------------------------------------------- +# Rendering (task pm0) +# ---------------------------------------------------------------------------- +# render_stats_page turns the STATS_* globals filled by the aggregation above +# into a static stats.html. The page is variable-length (each category may be +# empty, sparse, or large) which does not fit the fixed field-spec template +# engine cleanly, so we follow the same approach view/details pages use for +# their dynamic EXIF table: build the whole body as an HTML string here, hand it +# to stats.tmpl through the raw context field stats_body, and let the engine wrap +# it with the shared header/footer chrome. Bars are plain CSS (width as a percent +# of the section's top bucket) so the output stays JavaScript-free. + +# Print the largest counter in the named stats array, or 0 when it is empty. +# Used to scale each section's bars relative to its own busiest bucket. +_stats_max_count() { + local -n counts_ref="$1"; shift + local key + local -i max=0 + + for key in "${!counts_ref[@]}"; do + if (( counts_ref[$key] > max )); then + max=${counts_ref[$key]} + fi + done + printf '%d' "$max" +} + +# Print the integer percentage count/total (0 when total is 0). awk keeps the +# rounding off bash integer math; STATS_TOTALS[photos] is the denominator. +_stats_percent() { + local -ri count="$1"; shift + local -ri total="$1"; shift + + if (( total <= 0 )); then + printf '0' + return + fi + awk -v c="$count" -v t="$total" 'BEGIN { printf "%.0f", 100 * c / t }' +} + +# Emit one bar-chart <li>: an already-escaped label, a CSS-width bar scaled to +# the section maximum, and the count plus its share of all photos. Callers escape +# labels themselves because some come from EXIF (camera/lens) and some are +# trusted bucket names we build internally. +_stats_bar_row() { + local -r label_html="$1"; shift + local -ri count="$1"; shift + local -ri total="$1"; shift + local -ri max="$1"; shift + local -i width=0 + + if (( max > 0 )); then + width=$(( 100 * count / max )) + fi + printf ' <li>' + printf '<span class="stats-label">%s</span>' "$label_html" + printf '<span class="stats-bar-track">' + printf '<span class="stats-bar-fill" style="width:%d%%"></span></span>' \ + "$width" + printf '<span class="stats-count">%d (%s%%)</span>' \ + "$count" "$(_stats_percent "$count" "$total")" + printf '</li>\n' +} + +# Open a <section> with an escaped heading and the <ul> bar container. Split from +# the row emitters so every section shares identical chrome. +_stats_section_open() { + local -r heading="$1"; shift + local heading_html + + heading_html=$(_html_escape "$heading") + printf '<section class="stats-section">\n' + printf '<h2>%s</h2>\n' "$heading_html" + printf '<ul class="stats-bars">\n' +} + +_stats_section_close() { + printf '</ul>\n</section>\n' +} + +# Render the camera leaderboard: one bar per camera, sorted by count descending, +# each label linking to camera-<slug>.html (built by sibling task um0). Camera +# labels come from EXIF, so the label text is HTML-escaped; the slug is filename +# -safe by construction. Skipped entirely when no camera data was collected. +_stats_render_camera_section() { + local -ri total="$1"; shift + local label + local label_html + local link_html + local slug + local -i max + + if (( ${#STATS_CAMERAS[@]} == 0 )); then + return + fi + max=$(_stats_max_count STATS_CAMERAS) + _stats_section_open 'Camera leaderboard' + while IFS= read -r label; do + label_html=$(_html_escape "$label") + slug="${STATS_CAMERA_SLUGS[$label]}" + link_html=$(printf '<a href="camera-%s.html">%s</a>' \ + "$slug" "$label_html") + _stats_bar_row "$link_html" "${STATS_CAMERAS[$label]}" "$total" "$max" + done < <(_stats_keys_by_count_desc STATS_CAMERAS) + _stats_section_close +} + +# Print an array's keys ordered by descending count (ties broken by key) so the +# busiest bucket leads. Used for the leaderboard and other count-ranked sections. +_stats_keys_by_count_desc() { + local -n counts_ref="$1"; shift + local key + + for key in "${!counts_ref[@]}"; do + printf '%d\t%s\n' "${counts_ref[$key]}" "$key" + done | sort -t $'\t' -k1,1nr -k2,2 | cut -f2- +} + +# Render a histogram section using an explicit bucket order (e.g. apertures from +# wide to narrow) rather than count ranking, so the axis reads naturally. Only +# buckets that actually occurred are emitted, and the whole section is skipped +# when none did. Bucket labels are internal/trusted but still escaped for safety. +_stats_render_ordered_section() { + local -r heading="$1"; shift + local -r array_name="$1"; shift + local -ri total="$1"; shift + local -n counts_ref="$array_name" + local bucket + local label_html + local -i max + + if (( ${#counts_ref[@]} == 0 )); then + return + fi + max=$(_stats_max_count "$array_name") + _stats_section_open "$heading" + for bucket in "$@"; do + if [ -z "${counts_ref[$bucket]:-}" ]; then + continue + fi + label_html=$(_html_escape "$bucket") + _stats_bar_row "$label_html" "${counts_ref[$bucket]}" "$total" "$max" + done + _stats_section_close +} + +# Render a section ranked by count (cameras aside). Used where there is no +# natural axis order: years, lenses, and the decoded enum categories. +_stats_render_ranked_section() { + local -r heading="$1"; shift + local -r array_name="$1"; shift + local -ri total="$1"; shift + local -n counts_ref="$array_name" + local key + local label_html + local -i max + + if (( ${#counts_ref[@]} == 0 )); then + return + fi + max=$(_stats_max_count "$array_name") + _stats_section_open "$heading" + while IFS= read -r key; do + label_html=$(_html_escape "$key") + _stats_bar_row "$label_html" "${counts_ref[$key]}" "$total" "$max" + done < <(_stats_keys_by_count_desc "$array_name") + _stats_section_close +} + +# Render the temporal sections. Years rank by count; months walk Jan..Dec in +# calendar order using human month names for the labels. +_stats_render_temporal_sections() { + local -ri total="$1"; shift + + _stats_render_ranked_section 'Photos per year' STATS_YEARS "$total" + _stats_render_month_section "$total" +} + +# Render the per-month histogram in calendar order. The aggregator keys months +# by zero-padded number (01..12); this maps each to its English name so the axis +# is readable, and reuses the ordered-section omit-when-empty behaviour inline. +_stats_render_month_section() { + local -ri total="$1"; shift + local -ra month_names=( + '' January February March April May June July August + September October November December ) + local -i month + local key + local -i max + + if (( ${#STATS_MONTHS[@]} == 0 )); then + return + fi + max=$(_stats_max_count STATS_MONTHS) + _stats_section_open 'Photos per month' + for (( month = 1; month <= 12; month++ )); do + key=$(printf '%02d' "$month") + if [ -z "${STATS_MONTHS[$key]:-}" ]; then + continue + fi + _stats_bar_row "${month_names[$month]}" \ + "${STATS_MONTHS[$key]}" "$total" "$max" + done + _stats_section_close +} + +# Render the exposure histograms in photographer-friendly axis order (the same +# bucket ladders the aggregator's *_bucket helpers produce). +_stats_render_exposure_sections() { + local -ri total="$1"; shift + + _stats_render_ordered_section 'Aperture' STATS_APERTURE "$total" \ + 'f/1.8 or wider' 'f/2' 'f/2.8' 'f/4' 'f/5.6' 'f/8' 'f/11' 'f/16' \ + 'f/22 or narrower' + _stats_render_ordered_section 'Shutter speed' STATS_SHUTTER "$total" \ + '1/4000s or faster' '1/2000s' '1/1000s' '1/500s' '1/250s' '1/125s' \ + '1/60s' '1/30s' '1/15s' '1/8s' '1/4s' '1/2s' '1s' 'longer than 1s' + _stats_render_ordered_section 'ISO' STATS_ISO "$total" \ + '50' '100' '200' '400' '800' '1600' '3200' '6400' '12800' '25600' \ + 'over 25600' + _stats_render_ordered_section 'Focal length' STATS_FOCAL "$total" \ + 'under 24mm' '24-35mm' '35-70mm' '70-135mm' '135-200mm' 'over 200mm' +} + +# Render the dimension histograms (megapixels, aspect ratio, orientation) and +# the file-format breakdown, each in its natural axis order. +_stats_render_dimension_sections() { + local -ri total="$1"; shift + + _stats_render_ordered_section 'Megapixels' STATS_MEGAPIXELS "$total" \ + 'under 2MP' '2-5MP' '5-10MP' '10-20MP' '20-40MP' '40-80MP' 'over 80MP' + _stats_render_ordered_section 'Aspect ratio' STATS_ASPECT "$total" \ + '3:2' '4:3' '16:9' '1:1' '5:4' 'other' + _stats_render_ordered_section 'Orientation' STATS_ORIENTATION "$total" \ + 'Landscape' 'Portrait' 'Square' + _stats_render_ordered_section 'File format' STATS_FORMAT "$total" \ + 'JPEG' 'PNG' 'WEBP' 'GIF' 'other' +} + +# Render the decoded enum sections and the (sparse) lens leaderboard. All rank by +# count and self-skip when empty, so absent tags simply omit their section. +_stats_render_enum_sections() { + local -ri total="$1"; shift + + _stats_render_ranked_section 'Lenses' STATS_LENSES "$total" + _stats_render_ranked_section 'Exposure program' \ + STATS_EXPOSURE_PROGRAM "$total" + _stats_render_ranked_section 'Metering mode' STATS_METERING "$total" + _stats_render_ranked_section 'White balance' STATS_WHITE_BALANCE "$total" + _stats_render_ranked_section 'Flash' STATS_FLASH "$total" +} + +# Assemble the full stats body from every section in display order. Returns the +# HTML on stdout; render_stats_page captures it into the stats_body context var. +_stats_build_body() { + local -ri total="${STATS_TOTALS[photos]:-0}" + + printf '<p class="stats-total">%d photos analysed.</p>\n' "$total" + _stats_render_camera_section "$total" + _stats_render_temporal_sections "$total" + _stats_render_exposure_sections "$total" + _stats_render_dimension_sections "$total" + _stats_render_enum_sections "$total" +} + +# Public render entry point (handoff for task rm0). Builds the body from the +# already-populated STATS_* globals and renders stats.html via the template +# engine, wrapping the body with the shared header/footer the way view/details +# pages do. Call collect_photo_exif_stats first to fill the globals. +# render_stats_page <html_dir> <backhref> [page_name] +# html_dir is the dist-relative output directory (top-level album: "."), +# backhref is the relative path back to the album root ("." for a top-level +# stats.html), and page_name defaults to "stats" -> stats.html. +render_stats_page() { + local -r html_dir="$1"; shift + local -r backhref="$1"; shift + local -r page_name="${1:-stats}" + local stats_body + + stats_body=$(_stats_build_body) + template header "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + blurs_dir '' \ + background_image '' \ + show_header_bar 'yes' + template stats "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + stats_body "$stats_body" + template footer "$page_name.html" \ + html_dir "$html_dir" \ + backhref "$backhref" \ + tarball_name '' +} |
