Skip to main content

makeover_layout/
tag_facet.rs

1//! A tag tree as a [`Selecting::Subtree`] facet, from tagtree's three answers.
2//!
3//! [`Selecting::Subtree`] leaves resolution to the app, and tagtree (published
4//! as `mnw-tagtree`) is where the resolution lives: an outline for the rows in
5//! draw order, a selection for how each row stands, and a rollup for how many
6//! items sit under each. This is the mapping from those to [`FacetValue`]s, in
7//! one place rather than once per app. Behind the `tagtree` feature, so a
8//! consumer that draws no tag tree takes no dependency for it.
9//!
10//! Which rows to show stays the app's, as [`Facet`] says, and
11//! [`tagtree::Selection::reveals`] is the usual answer: roots, the children of
12//! what was picked, and the path down to every decision.
13//!
14//! [`grouped`] is the same mapping for a [`tagtree::TagQuery`], where picks
15//! combine per parent and groups are headings: opening one is view state, not
16//! a pick, and each group carries how the picks under it combine and where it
17//! ranks.
18
19use crate::{FacetValue, GroupRank, Join, Nesting, Standing};
20
21impl From<tagtree::Standing> for Standing {
22    fn from(standing: tagtree::Standing) -> Self {
23        match standing {
24            tagtree::Standing::Open => Self::Open,
25            tagtree::Standing::Taken => Self::Taken,
26            tagtree::Standing::Inherited => Self::Inherited,
27            tagtree::Standing::Pruned => Self::Pruned,
28            tagtree::Standing::PrunedAbove => Self::PrunedAbove,
29        }
30    }
31}
32
33/// One facet value per outline row, standing from `selection` and counted from
34/// `counts` when the app measured them.
35///
36/// `rows` is whichever part of [`tagtree::TagIndex::outline`] the app chose to
37/// show, in the order it came: the outline is pre-order, which is draw order.
38/// A row's [`depth`](FacetValue::depth) counts from zero at a root, where
39/// tagtree counts segments from one. `branching` is the outline's own answer,
40/// whether anything hangs below the node in the index, rather than whether its
41/// children happen to be shown, so a reader is told a branch opens before
42/// pressing it.
43///
44/// ```
45/// use makeover_layout::{Standing, tag_facet::values};
46///
47/// let index = tagtree::TagIndex::new(vec!["audio.genre.ambient".into(), "video.essay".into()]);
48/// let mut selection = tagtree::Selection::new();
49/// selection.pick("audio");
50/// let outline = index.outline();
51/// let shown: Vec<_> = outline.into_iter().filter(|n| selection.reveals(n.path)).collect();
52/// let rollup = tagtree::Rollup::from_sets([(vec!["audio.genre.ambient"], 3)]);
53///
54/// let facet = values(&shown, &selection, Some(&rollup));
55/// let audio = &facet[0];
56/// assert_eq!((audio.value, audio.standing, audio.count), ("audio", Standing::Taken, Some(3)));
57/// assert_eq!(facet[1].value, "audio.genre");
58/// assert_eq!(facet[1].standing, Standing::Inherited);
59/// assert_eq!(facet[1].depth.level, 1);
60/// ```
61#[must_use]
62pub fn values<'a>(
63    rows: &[tagtree::OutlineNode<'a>],
64    selection: &tagtree::Selection,
65    counts: Option<&tagtree::Rollup>,
66) -> Vec<FacetValue<'a>> {
67    rows.iter()
68        .map(|row| {
69            let value = FacetValue::new(row.path, row.label)
70                .standing(selection.standing(row.path).into())
71                .at(
72                    Nesting::at(u8::try_from(row.depth.saturating_sub(1)).unwrap_or(u8::MAX)),
73                    row.branching,
74                );
75            match counts {
76                Some(rollup) => value.counted(rollup.total(row.path)),
77                None => value,
78            }
79        })
80        .collect()
81}
82
83impl From<tagtree::Join> for Join {
84    fn from(join: tagtree::Join) -> Self {
85        match join {
86            tagtree::Join::Any => Self::Any,
87            tagtree::Join::All => Self::All,
88        }
89    }
90}
91
92/// How `path` stands in `query`: picked or excluded itself, else in or out
93/// because of the nearest pick or exclusion above it, else open.
94#[must_use]
95pub fn query_standing(query: &tagtree::TagQuery, path: &str) -> Standing {
96    if query.is_picked(path) {
97        return Standing::Taken;
98    }
99    if query.is_excluded(path) {
100        return Standing::Pruned;
101    }
102    for above in tagtree::ancestors(path).into_iter().rev() {
103        if query.is_excluded(above) {
104            return Standing::PrunedAbove;
105        }
106        if query.is_picked(above) {
107            return Standing::Inherited;
108        }
109    }
110    Standing::Open
111}
112
113/// One facet value per outline row, for a query whose picks combine per parent.
114///
115/// - `pickable` says which nodes may be picked (leaves, plus inner nodes the
116///   app flags); every other node is a [heading](FacetValue::heading).
117/// - `open` is the app's view state for a branching node: opened by the
118///   visitor, or by the ranking. Opening is never picking.
119/// - `ranks` is [`tagtree::rank_groups`]'s answer for the groups on offer, in
120///   rank order. Each ranked group gets a [`GroupRank`] whose cut is its score
121///   against the best live group's, and the first live group not in use is
122///   marked as where to start. Pass the top-level ranking.
123/// - `count` answers, per pickable value, how many results picking it gives
124///   and whether that number is a gain (`+n`) rather than a total.
125///
126/// ```
127/// use makeover_layout::{Join, Standing, tag_facet::grouped};
128///
129/// let index = tagtree::TagIndex::new(vec!["genre.ambient".into(), "genre.drone".into()]);
130/// let mut query = tagtree::TagQuery::new();
131/// query.pick("genre.ambient");
132/// let outline = index.outline();
133/// let leaf = |p: &str| p.contains('.');
134/// let facet = grouped(&outline, &query, leaf, |_| true, &[], |_| Some((2, true)));
135/// assert!(!facet[0].pickable && facet[0].is_open());
136/// assert_eq!(facet[0].join, Some(Join::Any));
137/// assert_eq!(facet[1].standing, Standing::Taken);
138/// assert_eq!((facet[2].count, facet[2].gain), (Some(2), true));
139/// ```
140#[must_use]
141pub fn grouped<'a>(
142    rows: &[tagtree::OutlineNode<'a>],
143    query: &tagtree::TagQuery,
144    pickable: impl Fn(&str) -> bool,
145    open: impl Fn(&str) -> bool,
146    ranks: &[tagtree::GroupRank],
147    count: impl Fn(&str) -> Option<(u64, bool)>,
148) -> Vec<FacetValue<'a>> {
149    let best = ranks
150        .iter()
151        .filter(|r| r.live && !r.in_use)
152        .map(|r| r.score)
153        .fold(0.0_f64, f64::max);
154    let start = ranks
155        .iter()
156        .find(|r| r.live && !r.in_use)
157        .map(|r| r.path.as_str());
158    rows.iter()
159        .map(|row| {
160            let path = row.path;
161            let mut value = FacetValue::new(path, row.label)
162                .standing(query_standing(query, path))
163                .at(
164                    Nesting::at(u8::try_from(row.depth.saturating_sub(1)).unwrap_or(u8::MAX)),
165                    row.branching,
166                );
167            if row.branching {
168                value = value.opened(open(path)).joined(query.join(path).into());
169            }
170            if pickable(path) {
171                if let Some((n, gain)) = count(path) {
172                    value = value.counted(n);
173                    if gain {
174                        value = value.gained();
175                    }
176                }
177            } else {
178                value = value.heading();
179            }
180            if let Some(r) = ranks.iter().find(|r| r.path == path) {
181                let rank = if r.in_use {
182                    GroupRank::in_use()
183                } else if !r.live {
184                    GroupRank::dead()
185                } else {
186                    let cut = if best > 0.0 {
187                        r.score / best * 100.0
188                    } else {
189                        0.0
190                    };
191                    // A live group always shows some bar: zero would read as dead.
192                    GroupRank::cutting((cut.round() as u8).max(4))
193                };
194                value = value.ranked(if start == Some(path) {
195                    rank.starting()
196                } else {
197                    rank
198                });
199            }
200            value
201        })
202        .collect()
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn every_tagtree_standing_maps_to_its_namesake() {
211        assert_eq!(Standing::from(tagtree::Standing::Open), Standing::Open);
212        assert_eq!(Standing::from(tagtree::Standing::Taken), Standing::Taken);
213        assert_eq!(
214            Standing::from(tagtree::Standing::Inherited),
215            Standing::Inherited
216        );
217        assert_eq!(Standing::from(tagtree::Standing::Pruned), Standing::Pruned);
218        assert_eq!(
219            Standing::from(tagtree::Standing::PrunedAbove),
220            Standing::PrunedAbove
221        );
222    }
223
224    #[test]
225    fn an_unmeasured_facet_carries_no_counts() {
226        let index = tagtree::TagIndex::new(vec!["a.b".into()]);
227        let outline = index.outline();
228        let facet = values(&outline, &tagtree::Selection::new(), None);
229        assert!(facet.iter().all(|v| v.count.is_none()));
230        assert!(facet[0].branching && !facet[1].branching);
231        assert_eq!(facet[0].label, "a");
232        assert_eq!(facet[1].label, "b");
233    }
234
235    #[test]
236    fn a_prune_reads_out_below_it() {
237        let index = tagtree::TagIndex::new(vec!["a.b.c".into()]);
238        let outline = index.outline();
239        let mut selection = tagtree::Selection::taking(["a"]);
240        selection.prune("a.b");
241        let facet = values(&outline, &selection, None);
242        let standings: Vec<Standing> = facet.iter().map(|v| v.standing).collect();
243        assert_eq!(
244            standings,
245            [Standing::Taken, Standing::Pruned, Standing::PrunedAbove]
246        );
247    }
248
249    #[test]
250    fn a_query_reads_nearest_decision_first() {
251        let mut query = tagtree::TagQuery::new();
252        query.pick("a.b");
253        query.exclude("a.b.c");
254        assert_eq!(query_standing(&query, "a.b"), Standing::Taken);
255        assert_eq!(query_standing(&query, "a.b.x"), Standing::Inherited);
256        assert_eq!(query_standing(&query, "a.b.c.d"), Standing::PrunedAbove);
257        assert_eq!(query_standing(&query, "a"), Standing::Open);
258    }
259
260    #[test]
261    fn ranks_scale_to_the_best_group_and_mark_where_to_start() {
262        let index =
263            tagtree::TagIndex::new(vec!["m.a".into(), "g.x".into(), "p.l".into(), "f.y".into()]);
264        let outline = index.outline();
265        let rank = |path: &str, in_use, live, score| tagtree::GroupRank {
266            path: path.into(),
267            in_use,
268            live,
269            score,
270            cut: 0.0,
271            coverage: 0.0,
272            choices: 1,
273        };
274        let ranks = [
275            rank("m", true, true, 1.0),
276            rank("g", false, true, 0.2),
277            rank("f", false, true, 0.1),
278            rank("p", false, false, 0.0),
279        ];
280        let mut query = tagtree::TagQuery::new();
281        query.pick("m.a");
282        let facet = grouped(
283            &outline,
284            &query,
285            |p| p.contains('.'),
286            |_| false,
287            &ranks,
288            |_| None,
289        );
290        let by = |p: &str| {
291            facet
292                .iter()
293                .find(|v| v.value == p)
294                .and_then(|v| v.rank)
295                .unwrap()
296        };
297        assert!(by("m").in_use);
298        assert_eq!((by("g").cut, by("g").start), (100, true));
299        assert_eq!((by("f").cut, by("f").start), (50, false));
300        assert!(!by("p").live);
301    }
302}