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}