summaryrefslogtreecommitdiff
path: root/src/lib/stats-render.source.sh
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/stats-render.source.sh')
-rw-r--r--src/lib/stats-render.source.sh396
1 files changed, 396 insertions, 0 deletions
diff --git a/src/lib/stats-render.source.sh b/src/lib/stats-render.source.sh
new file mode 100644
index 0000000..66ff6b4
--- /dev/null
+++ b/src/lib/stats-render.source.sh
@@ -0,0 +1,396 @@
+# 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 <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. An optional second
+# argument adds an extra CSS class to the <ul> (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 '<section class="stats-section">\n'
+ printf '<h2>%s</h2>\n' "$heading_html"
+ printf '<ul class="%s">\n' "$ul_class"
+}
+
+_stats_section_close() {
+ printf '</ul>\n</section>\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
+# "<prefix>\x1f<label>"; 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 "<pagebase>.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/<pagebase>/index.html, so link relative to the overview.
+ printf '<a href="%s/index.html">%s</a>' "$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 '<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.
+# 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 ''
+}