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 = 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}