Let suggestion filtering narrow to a specific archetype or tribe
Filtering by category alone wasn't granular enough: a deck usually matches several archetypes and tribes at once, so "show me Archetype suggestions" still returns most of the list. SynergyReason now carries the matched thing itself (`subject`) alongside the human-readable `detail`, populated at all four match sites. The modal adds a second chip row listing every subject present in the current suggestions, sorted by how many cards each explains and labelled with that count, so the most useful narrowing is obvious before clicking.
This commit is contained in:
@@ -92,6 +92,11 @@ pub fn compute_themes(oracle_text: &str) -> i64 {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SynergyReason {
|
||||
pub category: String,
|
||||
/// The specific thing matched — "Dragon", "Flying", "Landfall". Carried
|
||||
/// separately from `detail` so the UI can filter on it: knowing a
|
||||
/// suggestion is an Archetype hit is rarely enough, since a deck usually
|
||||
/// has several and you want one of them.
|
||||
pub subject: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
@@ -411,7 +416,11 @@ impl Catalog {
|
||||
let n = tribal_counts.get(s).copied().unwrap_or(0);
|
||||
format!("Shares creature type: {s} ({n} in deck)")
|
||||
};
|
||||
reasons.push(SynergyReason { category: "Tribal".into(), detail });
|
||||
reasons.push(SynergyReason {
|
||||
category: "Tribal".into(),
|
||||
subject: s.clone(),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
// Mechanic
|
||||
@@ -430,7 +439,11 @@ impl Catalog {
|
||||
let n = keyword_counts.get(k).copied().unwrap_or(0);
|
||||
format!("Shares keyword: {k} ({n} in deck)")
|
||||
};
|
||||
reasons.push(SynergyReason { category: "Mechanic".into(), detail });
|
||||
reasons.push(SynergyReason {
|
||||
category: "Mechanic".into(),
|
||||
subject: k.clone(),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
// Archetype
|
||||
@@ -444,6 +457,7 @@ impl Catalog {
|
||||
score += ARCHETYPE_POINTS;
|
||||
reasons.push(SynergyReason {
|
||||
category: "Archetype".into(),
|
||||
subject: (*name).to_string(),
|
||||
detail: format!("{name} payoff/enabler"),
|
||||
});
|
||||
arche += 1;
|
||||
@@ -461,6 +475,7 @@ impl Catalog {
|
||||
.collect();
|
||||
reasons.push(SynergyReason {
|
||||
category: "Mana base".into(),
|
||||
subject: letters.clone(),
|
||||
detail: format!("Produces {letters}, which your deck can't currently make"),
|
||||
});
|
||||
}
|
||||
@@ -790,4 +805,23 @@ mod tests {
|
||||
let d = cat.create_deck("Empty", None).unwrap();
|
||||
assert!(cat.suggest_cards(d.id, 50, 0, false).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod subject_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_reason_names_the_specific_thing_it_matched() {
|
||||
// The subject is what the UI filters on, so it must be the bare
|
||||
// "Dragon"/"Landfall" rather than the sentence shown to the user —
|
||||
// otherwise picking one archetype would mean matching prose.
|
||||
let r = SynergyReason {
|
||||
category: "Archetype".into(),
|
||||
subject: "Landfall".into(),
|
||||
detail: "Landfall payoff/enabler".into(),
|
||||
};
|
||||
assert_eq!(r.subject, "Landfall");
|
||||
assert!(r.detail.contains(&r.subject), "detail should still read naturally");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ export function SuggestionsModal({
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [activeCategories, setActiveCategories] = useState<Set<string>>(new Set());
|
||||
// Second level: the specific tribe/keyword/archetype. Knowing a suggestion is
|
||||
// an "Archetype" hit is rarely enough — a deck usually has several and you
|
||||
// want one of them.
|
||||
const [activeSubjects, setActiveSubjects] = useState<Set<string>>(new Set());
|
||||
const [unusual, setUnusual] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,15 +104,52 @@ export function SuggestionsModal({
|
||||
else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
// Subjects belong to categories, so narrowing the categories can strand a
|
||||
// selected subject that's no longer offered — clear rather than leave an
|
||||
// invisible filter applied.
|
||||
setActiveSubjects(new Set());
|
||||
}
|
||||
|
||||
function toggleSubject(subject: string) {
|
||||
setActiveSubjects((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(subject)) next.delete(subject);
|
||||
else next.add(subject);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const availableCategories = suggestions
|
||||
? CATEGORY_ORDER.filter((cat) => suggestions.some((s) => s.reasons.some((r) => r.category === cat)))
|
||||
: [];
|
||||
const visible =
|
||||
suggestions && activeCategories.size > 0
|
||||
? suggestions.filter((s) => s.reasons.some((r) => activeCategories.has(r.category)))
|
||||
: suggestions;
|
||||
/** Reasons still in play once the category chips are applied. */
|
||||
const inCategory = (r: { category: string }) =>
|
||||
activeCategories.size === 0 || activeCategories.has(r.category);
|
||||
|
||||
// Offered subjects follow the category selection, so the second row is always
|
||||
// a drill-down into the first rather than an unrelated list. Ordered by how
|
||||
// many suggestions each explains, so the most useful narrowing comes first.
|
||||
const subjectCounts = new Map<string, { count: number; category: string }>();
|
||||
for (const s of suggestions ?? []) {
|
||||
for (const r of s.reasons) {
|
||||
if (!inCategory(r) || !r.subject) continue;
|
||||
const prev = subjectCounts.get(r.subject);
|
||||
subjectCounts.set(r.subject, {
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
category: prev?.category ?? r.category,
|
||||
});
|
||||
}
|
||||
}
|
||||
const availableSubjects = [...subjectCounts.entries()]
|
||||
.sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]))
|
||||
.map(([subject, meta]) => ({ subject, ...meta }));
|
||||
|
||||
const visible = suggestions?.filter((s) =>
|
||||
s.reasons.some(
|
||||
(r) =>
|
||||
inCategory(r) && (activeSubjects.size === 0 || activeSubjects.has(r.subject)),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogFrame title="Suggested cards" onCancel={onClose} wide>
|
||||
@@ -155,7 +196,40 @@ export function SuggestionsModal({
|
||||
<button
|
||||
type="button"
|
||||
className="filter-chip-clear"
|
||||
onClick={() => setActiveCategories(new Set())}
|
||||
onClick={() => {
|
||||
setActiveCategories(new Set());
|
||||
setActiveSubjects(new Set());
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableSubjects.length > 1 && (
|
||||
<div className="suggestion-filters">
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
Narrow to:
|
||||
</span>
|
||||
{availableSubjects.map(({ subject, count, category }) => (
|
||||
<button
|
||||
key={subject}
|
||||
type="button"
|
||||
className={`filter-chip filter-chip-${categorySlug(category)} ${
|
||||
activeSubjects.has(subject) ? "active" : ""
|
||||
}`}
|
||||
onClick={() => toggleSubject(subject)}
|
||||
title={`${category} · ${count} suggestion${count === 1 ? "" : "s"}`}
|
||||
>
|
||||
{subject} <span className="filter-chip-count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
{activeSubjects.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="filter-chip-clear"
|
||||
onClick={() => setActiveSubjects(new Set())}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
|
||||
@@ -70,6 +70,10 @@ export interface LibraryImportResult {
|
||||
|
||||
export interface SynergyReason {
|
||||
category: string; // "Tribal" | "Mechanic" | "Archetype" | "Mana base"
|
||||
/** The specific thing matched — "Dragon", "Flying", "Landfall". Separate from
|
||||
* `detail` so the UI can filter on it: a deck usually has several archetypes
|
||||
* and you want one of them, not all. */
|
||||
subject: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -2757,3 +2757,12 @@ tbody tr:hover {
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Suggestion count inside a "narrow to" chip — how many suggestions that
|
||||
specific tribe/keyword/archetype explains, so the most useful narrowing is
|
||||
obvious before clicking. */
|
||||
.filter-chip-count {
|
||||
opacity: 0.55;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user