diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index 23ca044..aca35d4 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -96,7 +96,10 @@ impl Granularity { /// Applied to **local** time, not UTC: "everything from 3 August" means /// the photographer's 3 August, which is why `captured_offset` is stored /// alongside the UTC timestamp. - fn strftime(self) -> &'static str { + /// Public so a caller that must build its own bucketing query — one + /// joining collection membership, say — buckets identically to + /// [`Catalog::timeline_range`] rather than reimplementing the format. + pub fn strftime(self) -> &'static str { match self { Granularity::Year => "%Y", Granularity::Month => "%Y-%m", diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 5428e67..2ea5dbe 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -2630,6 +2630,100 @@ pub fn total_images_scoped( Ok(n as usize) } +/// The SQL restricting a query to `scope` and its descendants, with the bound +/// parameters to go with it. +/// +/// Shared by the span and the histogram so the two cannot drift: an axis drawn +/// over one set of images and bars counted over another puts the bars in the +/// wrong place. +fn scope_clause( + catalog: &Catalog, + scope: Option, +) -> Result<(String, Vec), dr_catalog::CatalogError> { + let Some(scope) = scope else { + return Ok((String::new(), Vec::new())); + }; + let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + Ok(( + format!( + " AND i.id IN (SELECT image_id FROM collection_members + WHERE collection_id IN ({placeholders}))" + ), + ids.iter() + .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) + .collect(), + )) +} + +/// Earliest and latest capture time within `scope`, honouring the filter. +/// +/// The timeline's extent. Taken over the same images the histogram counts, so +/// opening a collection shows that collection's years rather than the whole +/// library's — the axis was previously spanning everything, which left a +/// collection's bars crushed into a sliver of it. +pub fn span_scoped( + catalog: &Catalog, + scope: Option, + filter: &RatingFilter, +) -> Option<(i64, i64)> { + let (clause, params) = scope_clause(catalog, scope).ok()?; + let rated = filter.sql(); + let sql = format!( + "SELECT min(i.captured_at), max(i.captured_at) FROM images i + WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}" + ); + catalog + .connection() + .query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| { + Ok((r.get::<_, Option>(0)?, r.get::<_, Option>(1)?)) + }) + .ok() + .and_then(|(lo, hi)| Some((lo?, hi?))) +} + +/// Histogram buckets within `scope` and a date range. +/// +/// `Catalog::timeline_range` counts the whole library; a collection needs the +/// membership join, and the rating filter applied too, or the bars disagree +/// with the grid beneath them. +pub fn timeline_scoped( + catalog: &Catalog, + scope: Option, + filter: &RatingFilter, + granularity: dr_catalog::Granularity, + from: i64, + to: i64, +) -> Result, dr_catalog::CatalogError> { + let (clause, mut params) = scope_clause(catalog, scope)?; + let rated = filter.sql(); + let sql = format!( + "SELECT min(i.captured_at) AS start, count(*) AS n FROM images i + WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause} + AND i.captured_at >= ? AND i.captured_at <= ? + GROUP BY strftime('{}', i.captured_at + + coalesce(i.captured_offset, 0) * 60, 'unixepoch') + ORDER BY start ASC", + granularity.strftime() + ); + params.push(rusqlite::types::Value::Integer(from)); + params.push(rusqlite::types::Value::Integer(to)); + + let conn = catalog.connection(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(dr_catalog::TimeBucket { + start: r.get(0)?, + count: r.get::<_, i64>(1)? as u32, + }) + })? + .collect::, _>>()?; + Ok(rows) +} + /// Total images in the catalog, honouring the rating filter. fn total_images_filtered( catalog: &Catalog, diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 95f9284..f7265ea 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -2720,7 +2720,12 @@ fn start_sweep(window: &AppWindow, ctl: &Rc) { /// from the data rather than fixing it means the histogram is informative at /// both scales instead of one flat bar or ten thousand slivers. fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc) { - let span = match catalog_span(catalog) { + // Scoped to whatever the grid is showing. A collection's histogram drawn + // over the whole library's span said almost nothing: every bar for a + // fortnight in Arosa landed in one column of a fifteen-year axis. + let scope = *ctl.scope.borrow(); + let filter = *ctl.filter.borrow(); + let span = match library::span_scoped(catalog, scope, &filter) { Some(s) => s, None => { // No dated images yet. An empty histogram is honest — EXIF is read @@ -2737,13 +2742,7 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc b, Err(e) => { log::debug!("timeline: {e}"); @@ -2875,17 +2874,14 @@ fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option) -> (i64, i64) { } /// Earliest and latest capture time in the catalog. -fn catalog_span(catalog: &Catalog) -> Option<(i64, i64)> { - catalog - .connection() - .query_row( - "SELECT min(captured_at), max(captured_at) - FROM images WHERE captured_at IS NOT NULL", - [], - |r| Ok((r.get::<_, Option>(0)?, r.get::<_, Option>(1)?)), - ) - .ok() - .and_then(|(lo, hi)| Some((lo?, hi?))) +/// The timeline's extent, over exactly the images the grid is showing. +/// +/// Scrubbing and zooming must measure the same span the histogram is drawn +/// over. When this read the whole library while the bars were scoped to a +/// collection, a scrub landed at an instant the collection did not contain and +/// the view jumped somewhere the user had not asked for. +fn catalog_span(catalog: &Catalog, ctl: &Rc) -> Option<(i64, i64)> { + library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow()) } /// Capture time of the image at row `ordinal` in the grid's own ordering. @@ -3003,13 +2999,6 @@ fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) { (if m <= 2 { y + 1 } else { y }, m, d, secs / 3600) } -fn now_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - /// Copy decoded RGBA into a Slint image. /// /// This is a CPU copy, which is acceptable here and not in the develop path: @@ -3318,7 +3307,7 @@ pub fn wire( let zoom = *ctl.timeline_zoom.borrow(); let centre = *ctl.timeline_centre.borrow(); - if let Some(full) = catalog_span(catalog) { + if let Some(full) = catalog_span(catalog, &ctl) { let (from, to) = zoomed_span(full, zoom, centre); w.set_library_current_bucket(when as i32); w.set_library_current_fraction( @@ -3362,7 +3351,7 @@ pub fn wire( let Some(catalog) = borrow.as_ref() else { return; }; - let Some(full) = catalog_span(catalog) else { + let Some(full) = catalog_span(catalog, &ctl) else { return; }; @@ -3410,7 +3399,7 @@ pub fn wire( let Some(catalog) = borrow.as_ref() else { return; }; - let Some(full) = catalog_span(catalog) else { + let Some(full) = catalog_span(catalog, &ctl) else { return; };