summaryrefslogtreecommitdiff
path: root/src/lib/stats.source.sh
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/stats.source.sh')
-rw-r--r--src/lib/stats.source.sh296
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 ''
+}