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`](crate::tag_facet::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 = value_of(row).standing(selection.standing(row.path).into());
70            match counts {
71                Some(rollup) => value.counted(rollup.total(row.path)),
72                None => value,
73            }
74        })
75        .collect()
76}
77
78/// A row's value at its depth, before any standing or count is said.
79fn value_of<'a>(row: &tagtree::OutlineNode<'a>) -> FacetValue<'a> {
80    FacetValue::new(row.path, row.label).at(
81        Nesting::at(u8::try_from(row.depth.saturating_sub(1)).unwrap_or(u8::MAX)),
82        row.branching,
83    )
84}
85
86impl From<tagtree::Join> for Join {
87    fn from(join: tagtree::Join) -> Self {
88        match join {
89            tagtree::Join::Any => Self::Any,
90            tagtree::Join::All => Self::All,
91        }
92    }
93}
94
95/// How `path` stands in `query`: picked or excluded itself, else in or out
96/// because of the nearest pick or exclusion above it, else open.
97#[must_use]
98pub fn query_standing(query: &tagtree::TagQuery, path: &str) -> Standing {
99    if query.is_picked(path) {
100        return Standing::Taken;
101    }
102    if query.is_excluded(path) {
103        return Standing::Pruned;
104    }
105    for above in tagtree::ancestors(path).into_iter().rev() {
106        if query.is_excluded(above) {
107            return Standing::PrunedAbove;
108        }
109        if query.is_picked(above) {
110            return Standing::Inherited;
111        }
112    }
113    Standing::Open
114}
115
116/// One facet value per outline row, for a query whose picks combine per parent.
117///
118/// - `pickable` says which nodes may be picked (leaves, plus inner nodes the
119///   app flags); every other node is a [heading](FacetValue::heading).
120/// - `open` is the app's view state for a branching node: opened by the
121///   visitor, or by the ranking. Opening is never picking.
122/// - `ranks` is [`tagtree::rank_groups`]'s answer for the groups on offer, in
123///   rank order. Each ranked group gets a [`GroupRank`] whose cut is its score
124///   against the best live group's, and the first live group not in use is
125///   marked as where to start. Pass the top-level ranking.
126/// - `count` answers, per pickable value, how many results picking it gives
127///   and whether that number is a gain (`+n`) rather than a total.
128///
129/// ```
130/// use makeover_layout::{Join, Standing, tag_facet::grouped};
131///
132/// let index = tagtree::TagIndex::new(vec!["genre.ambient".into(), "genre.drone".into()]);
133/// let mut query = tagtree::TagQuery::new();
134/// query.pick("genre.ambient");
135/// let outline = index.outline();
136/// let leaf = |p: &str| p.contains('.');
137/// let facet = grouped(&outline, &query, leaf, |_| true, &[], |_| Some((2, true)));
138/// assert!(!facet[0].pickable && facet[0].is_open());
139/// assert_eq!(facet[0].join, Some(Join::Any));
140/// assert_eq!(facet[1].standing, Standing::Taken);
141/// assert_eq!((facet[2].count, facet[2].gain), (Some(2), true));
142/// ```
143#[must_use]
144pub fn grouped<'a>(
145    rows: &[tagtree::OutlineNode<'a>],
146    query: &tagtree::TagQuery,
147    pickable: impl Fn(&str) -> bool,
148    open: impl Fn(&str) -> bool,
149    ranks: &[tagtree::GroupRank],
150    count: impl Fn(&str) -> Option<(u64, bool)>,
151) -> Vec<FacetValue<'a>> {
152    let best = ranks
153        .iter()
154        .filter(|r| r.live && !r.in_use)
155        .map(|r| r.score)
156        .fold(0.0_f64, f64::max);
157    let start = ranks
158        .iter()
159        .find(|r| r.live && !r.in_use)
160        .map(|r| r.path.as_str());
161    rows.iter()
162        .map(|row| {
163            let path = row.path;
164            let mut value = value_of(row).standing(query_standing(query, path));
165            if row.branching {
166                value = value.opened(open(path)).joined(query.join(path).into());
167            }
168            if pickable(path) {
169                if let Some((n, gain)) = count(path) {
170                    value = value.counted(n);
171                    if gain {
172                        value = value.gained();
173                    }
174                }
175            } else {
176                value = value.heading();
177            }
178            if let Some(r) = ranks.iter().find(|r| r.path == path) {
179                let rank = if r.in_use {
180                    GroupRank::in_use()
181                } else if !r.live {
182                    GroupRank::dead()
183                } else {
184                    let cut = if best > 0.0 {
185                        r.score / best * 100.0
186                    } else {
187                        0.0
188                    };
189                    // A live group always shows some bar: zero would read as dead.
190                    GroupRank::cutting((cut.round() as u8).max(4))
191                };
192                value = value.ranked(if start == Some(path) {
193                    rank.starting()
194                } else {
195                    rank
196                });
197            }
198            value
199        })
200        .collect()
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn every_tagtree_standing_maps_to_its_namesake() {
209        assert_eq!(Standing::from(tagtree::Standing::Open), Standing::Open);
210        assert_eq!(Standing::from(tagtree::Standing::Taken), Standing::Taken);
211        assert_eq!(
212            Standing::from(tagtree::Standing::Inherited),
213            Standing::Inherited
214        );
215        assert_eq!(Standing::from(tagtree::Standing::Pruned), Standing::Pruned);
216        assert_eq!(
217            Standing::from(tagtree::Standing::PrunedAbove),
218            Standing::PrunedAbove
219        );
220    }
221
222    #[test]
223    fn an_unmeasured_facet_carries_no_counts() {
224        let index = tagtree::TagIndex::new(vec!["a.b".into()]);
225        let outline = index.outline();
226        let facet = values(&outline, &tagtree::Selection::new(), None);
227        assert!(facet.iter().all(|v| v.count.is_none()));
228        assert!(facet[0].branching && !facet[1].branching);
229        assert_eq!(facet[0].label, "a");
230        assert_eq!(facet[1].label, "b");
231    }
232
233    #[test]
234    fn a_prune_reads_out_below_it() {
235        let index = tagtree::TagIndex::new(vec!["a.b.c".into()]);
236        let outline = index.outline();
237        let mut selection = tagtree::Selection::taking(["a"]);
238        selection.prune("a.b");
239        let facet = values(&outline, &selection, None);
240        let standings: Vec<Standing> = facet.iter().map(|v| v.standing).collect();
241        assert_eq!(
242            standings,
243            [Standing::Taken, Standing::Pruned, Standing::PrunedAbove]
244        );
245    }
246
247    #[test]
248    fn a_query_reads_nearest_decision_first() {
249        let mut query = tagtree::TagQuery::new();
250        query.pick("a.b");
251        query.exclude("a.b.c");
252        assert_eq!(query_standing(&query, "a.b"), Standing::Taken);
253        assert_eq!(query_standing(&query, "a.b.x"), Standing::Inherited);
254        assert_eq!(query_standing(&query, "a.b.c.d"), Standing::PrunedAbove);
255        assert_eq!(query_standing(&query, "a"), Standing::Open);
256    }
257
258    #[test]
259    fn ranks_scale_to_the_best_group_and_mark_where_to_start() {
260        let index =
261            tagtree::TagIndex::new(vec!["m.a".into(), "g.x".into(), "p.l".into(), "f.y".into()]);
262        let outline = index.outline();
263        let rank = |path: &str, in_use, live, score| tagtree::GroupRank {
264            path: path.into(),
265            in_use,
266            live,
267            score,
268            cut: 0.0,
269            coverage: 0.0,
270            choices: 1,
271        };
272        let ranks = [
273            rank("m", true, true, 1.0),
274            rank("g", false, true, 0.2),
275            rank("f", false, true, 0.1),
276            rank("p", false, false, 0.0),
277        ];
278        let mut query = tagtree::TagQuery::new();
279        query.pick("m.a");
280        let facet = grouped(
281            &outline,
282            &query,
283            |p| p.contains('.'),
284            |_| false,
285            &ranks,
286            |_| None,
287        );
288        let by = |p: &str| {
289            facet
290                .iter()
291                .find(|v| v.value == p)
292                .and_then(|v| v.rank)
293                .unwrap()
294        };
295        assert!(by("m").in_use);
296        assert_eq!((by("g").cut, by("g").start), (100, true));
297        assert_eq!((by("f").cut, by("f").start), (50, false));
298        assert!(!by("p").live);
299    }
300}