# Stats overview page rendering. Split out of stats.source.sh (task cn0) so the
# HTML/layout concern lives apart from the EXIF aggregation/bucketing (now in
# stats-aggregate.source.sh) and the per-filter mini-albums (now in
# stats-filter-album.source.sh). This module reads the STATS_* globals filled by
# collect_photo_exif_stats and turns them into the static stats overview page
# (bar charts, sections, camera leaderboard). All libs are sourced before run,
# so the STATS_* maps and the STATS_*_DIR constants defined in the sibling
# modules are available here at runtime.
# ----------------------------------------------------------------------------
# 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
: 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 ' '
printf '%s ' "$label_html"
printf ''
printf ' ' \
"$width"
printf '%d (%s%%) ' \
"$count" "$(_stats_percent "$count" "$total")"
printf ' \n'
}
# Open a with an escaped heading and the bar container. Split from
# the row emitters so every section shares identical chrome. An optional second
# argument adds an extra CSS class to the (e.g. the leaderboard uses it to
# space out and separate its rows of long, wrapping camera names).
_stats_section_open() {
local -r heading="$1"; shift
local -r list_class="${1:-}"
local heading_html
local ul_class='stats-bars'
if [ -n "$list_class" ]; then
ul_class+=" $list_class"
fi
heading_html=$(_html_escape "$heading")
printf '\n'
printf '%s \n' "$heading_html"
printf '\n' "$ul_class"
}
_stats_section_close() {
printf ' \n \n'
}
# Wrap an escaped label in a link to its filter mini-album. Every tallied bucket
# has a pagebase recorded in STATS_FILTER_PAGEBASE during aggregation, keyed by
# "\x1f"; if one is (unexpectedly) absent, the plain label is
# returned so the row still renders. The stats page and the filter pages share
# the dist root, so the href is just ".html".
_stats_filter_link() {
local -r prefix="$1"; shift
local -r label="$1"; shift
local -r label_html="$1"; shift
local -r catkey="$prefix$STATS_FILTER_KEYSEP$label"
local pagebase
pagebase="${STATS_FILTER_PAGEBASE[$catkey]:-}"
if [ -n "$pagebase" ]; then
# The stats overview lives at stats/index.html and each mini-album at
# stats//index.html, so link relative to the overview.
printf '%s ' "$pagebase" "$label_html"
else
printf '%s' "$label_html"
fi
}
# Render the camera leaderboard: one bar per camera, sorted by count descending,
# each label linking to its camera mini-album. Camera labels come from EXIF, so
# the text is HTML-escaped. Skipped entirely when no camera data was collected.
_stats_render_camera_section() {
local -ri total="$1"; shift
local label
local row_html
local -i max
if (( ${#STATS_CAMERAS[@]} == 0 )); then
return
fi
max=$(_stats_max_count STATS_CAMERAS)
_stats_section_open 'Camera leaderboard' 'stats-leaderboard'
while IFS= read -r label; do
row_html=$(_stats_filter_link camera "$label" "$(_html_escape "$label")")
_stats_bar_row "$row_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.
# LC_ALL=C pins the tie-break collation so the generated page is byte-identical
# across locales/machines (reproducible static output).
_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 | LC_ALL=C 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 -r prefix="$1"; shift
local -ri total="$1"; shift
local -n counts_ref="$array_name"
local bucket
local row_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
row_html=$(_stats_filter_link "$prefix" "$bucket" "$(_html_escape "$bucket")")
_stats_bar_row "$row_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 -r prefix="$1"; shift
local -ri total="$1"; shift
local -n counts_ref="$array_name"
local key
local row_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
row_html=$(_stats_filter_link "$prefix" "$key" "$(_html_escape "$key")")
_stats_bar_row "$row_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 year "$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 \
"$(_stats_filter_link month "$key" "${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 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 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 iso "$total" \
'50' '100' '200' '400' '800' '1600' '3200' '6400' '12800' '25600' \
'over 25600'
_stats_render_ordered_section 'Focal length' STATS_FOCAL 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 megapixels \
"$total" \
'under 2MP' '2-5MP' '5-10MP' '10-20MP' '20-40MP' '40-80MP' 'over 80MP'
_stats_render_ordered_section 'Aspect ratio' STATS_ASPECT aspect "$total" \
'3:2' '4:3' '16:9' '1:1' '5:4' 'other'
_stats_render_ordered_section 'Orientation' STATS_ORIENTATION orientation \
"$total" 'Landscape' 'Portrait' 'Square'
_stats_render_ordered_section 'File format' STATS_FORMAT 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 lens "$total"
_stats_render_ranked_section 'Exposure program' \
STATS_EXPOSURE_PROGRAM exposure-program "$total"
_stats_render_ranked_section 'Metering mode' STATS_METERING metering "$total"
_stats_render_ranked_section 'White balance' STATS_WHITE_BALANCE \
white-balance "$total"
_stats_render_ranked_section 'Flash' STATS_FLASH 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 '%d photos analysed.
\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 [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.
# Pick a seeded-random photo for a stats/camera page's blurred background, the
# same way the album preview pages do. The context seeds the choice so each page
# gets a stable (per RANDOM_SEED) but varied background. Degrades to an empty
# string (plain black background) when no photos exist, e.g. unit tests that
# render the page without a populated photos directory.
# Load the sorted photo list for blurred backgrounds once into a global. This
# runs for every filter page (thousands of them), so the per-call directory scan
# randomphoto would otherwise do dominates the build. render_filter_pages loads
# it before forking the render jobs so each background subshell inherits the
# populated array instead of rescanning.
_stats_load_background_photos() {
if [ -n "${STATS_BG_PHOTOS_LOADED:-}" ]; then
return
fi
declare -ga STATS_BG_PHOTOS=()
local photo
while IFS= read -r photo; do
STATS_BG_PHOTOS+=("$photo")
done < <(
find "$DIST_DIR/$STATS_PHOTOS_DIR" -maxdepth 1 -type f -printf '%f\n' \
2>/dev/null | sort
)
STATS_BG_PHOTOS_LOADED=yes
}
# Pick a seeded-random photo for a stats/camera page's blurred background from
# the cached photo list. Empty (plain black) when no photos exist.
_stats_random_background() {
local -r context="$1"; shift
local -i index
_stats_load_background_photos
if (( ${#STATS_BG_PHOTOS[@]} == 0 )); then
return
fi
index=$(random_index "photo:$STATS_PHOTOS_DIR:$context" \
"${#STATS_BG_PHOTOS[@]}")
printf '%s' "${STATS_BG_PHOTOS[index]}"
}
# Pick a seeded-random photo from a newline-separated list (a filter's own
# photos) for a filter gallery's blurred background, so the background fits the
# category. Empty when the list is empty.
_stats_pick_background() {
local -r context="$1"; shift
local -r photos="$1"; shift
local -a list=()
local photo
local -i index
while IFS= read -r photo; do
[ -n "$photo" ] && list+=("$photo")
done <<< "$photos"
if (( ${#list[@]} == 0 )); then
return
fi
index=$(random_index "photo:filter:$context" "${#list[@]}")
printf '%s' "${list[index]}"
}
render_stats_page() {
local -r html_dir="$1"; shift
local -r backhref="$1"; shift
local -r page_name="${1:-stats}"
local stats_body
local background_image
stats_body=$(_stats_build_body)
background_image=$(_stats_random_background "$page_name.html")
template header "$page_name.html" \
html_dir "$html_dir" \
backhref "$backhref" \
blurs_dir "$STATS_BLURS_DIR" \
background_image "$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 ''
}