Add deck analysis panel with mana production/consumption bars and oracle text search
Threads a new produced_mana/produces_colorless field through the whole card read path (ingest, library, filters, decks, search) so the deck view can show what colors a deck's manabase actually produces, alongside its existing color identity. Also adds a mana consumption bar (colored casting costs of nonland spells) and stacks the mana curve histogram by color. Adds an oracle-text substring filter to the library search, mirroring the existing location filter. Rearranges the deck sidebar list into two rows (name, then format chip + actions) so long names and action buttons no longer collide, and removes the drop shadows from the commander hero panel/image. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,24 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { api, CardRef, Deck, DeckEntry } from "../api";
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import { api, CardRef, COLORS, Deck, DeckEntry } from "../api";
|
||||
import {
|
||||
CardSearchBox,
|
||||
CardThumb,
|
||||
CardTile,
|
||||
ColorDistributionBar,
|
||||
ColorPips,
|
||||
ConfirmDialog,
|
||||
GroupBySelect,
|
||||
GroupMode,
|
||||
IconButton,
|
||||
Icons,
|
||||
ManaCurve,
|
||||
PromptDialog,
|
||||
QtyStepper,
|
||||
StatTile,
|
||||
ViewToggle,
|
||||
groupCards,
|
||||
useClickPreview,
|
||||
useHoverPreview,
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
@@ -29,6 +34,140 @@ function friendlyDeckError(err: unknown): string {
|
||||
return msg.includes("UNIQUE constraint failed") ? "A deck with that name already exists." : msg;
|
||||
}
|
||||
|
||||
interface DeckStats {
|
||||
totalCards: number;
|
||||
uniqueCards: number;
|
||||
landCount: number;
|
||||
avgManaValue: number;
|
||||
curve: {
|
||||
label: string;
|
||||
count: number;
|
||||
colorCounts: [number, number, number, number, number];
|
||||
colorlessCount: number;
|
||||
}[];
|
||||
colorCounts: [number, number, number, number, number];
|
||||
colorlessCount: number;
|
||||
manaProduction: [number, number, number, number, number];
|
||||
colorlessProduction: number;
|
||||
manaConsumption: [number, number, number, number, number];
|
||||
colorlessConsumption: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck composition stats, excluding the commander (shown separately in its
|
||||
* own hero display) — matches the usual "99 + commander" framing even for
|
||||
* non-Commander decks, where it's just "everything but the flagged card".
|
||||
*/
|
||||
function computeDeckStats(contents: DeckEntry[]): DeckStats {
|
||||
const cards = contents.filter((e) => !e.is_commander);
|
||||
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
|
||||
const lands = cards.filter((e) => e.type_line.includes("Land"));
|
||||
const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
|
||||
const landCount = lands.reduce((sum, e) => sum + e.quantity, 0);
|
||||
const nonlandCount = nonlands.reduce((sum, e) => sum + e.quantity, 0);
|
||||
const avgManaValue = nonlandCount
|
||||
? nonlands.reduce((sum, e) => sum + e.mana_value * e.quantity, 0) / nonlandCount
|
||||
: 0;
|
||||
|
||||
const bucketLabels = ["0", "1", "2", "3", "4", "5", "6+"];
|
||||
const curveBuckets = new Map(
|
||||
bucketLabels.map((l) => [
|
||||
l,
|
||||
{ count: 0, colorCounts: [0, 0, 0, 0, 0] as [number, number, number, number, number], colorlessCount: 0 },
|
||||
]),
|
||||
);
|
||||
for (const e of nonlands) {
|
||||
const label = e.mana_value >= 6 ? "6+" : String(Math.floor(e.mana_value));
|
||||
const bucket = curveBuckets.get(label)!;
|
||||
bucket.count += e.quantity;
|
||||
if (e.color_identity === 0) {
|
||||
bucket.colorlessCount += e.quantity;
|
||||
} else {
|
||||
COLORS.forEach((c, i) => {
|
||||
if (e.color_identity & c.bit) bucket.colorCounts[i] += e.quantity;
|
||||
});
|
||||
}
|
||||
}
|
||||
const curve = bucketLabels.map((label) => ({ label, ...curveBuckets.get(label)! }));
|
||||
|
||||
const colorCounts: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
||||
let colorlessCount = 0;
|
||||
for (const e of cards) {
|
||||
if (e.color_identity === 0) {
|
||||
colorlessCount += e.quantity;
|
||||
} else {
|
||||
COLORS.forEach((c, i) => {
|
||||
if (e.color_identity & c.bit) colorCounts[i] += e.quantity;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const manaProduction: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
||||
let colorlessProduction = 0;
|
||||
for (const e of cards) {
|
||||
if (e.produces_colorless) colorlessProduction += e.quantity;
|
||||
COLORS.forEach((c, i) => {
|
||||
if (e.produced_mana & c.bit) manaProduction[i] += e.quantity;
|
||||
});
|
||||
}
|
||||
|
||||
// Consumption: colored cost requirements of the spells actually cast —
|
||||
// lands are excluded since they have no colored casting cost.
|
||||
const manaConsumption: [number, number, number, number, number] = [0, 0, 0, 0, 0];
|
||||
let colorlessConsumption = 0;
|
||||
for (const e of nonlands) {
|
||||
if (e.colors === 0) {
|
||||
colorlessConsumption += e.quantity;
|
||||
} else {
|
||||
COLORS.forEach((c, i) => {
|
||||
if (e.colors & c.bit) manaConsumption[i] += e.quantity;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalCards,
|
||||
uniqueCards: cards.length,
|
||||
landCount,
|
||||
avgManaValue,
|
||||
curve,
|
||||
colorCounts,
|
||||
colorlessCount,
|
||||
manaProduction,
|
||||
colorlessProduction,
|
||||
manaConsumption,
|
||||
colorlessConsumption,
|
||||
};
|
||||
}
|
||||
|
||||
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
|
||||
function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void }) {
|
||||
const { handlers } = useHoverPreview();
|
||||
const { open } = useClickPreview();
|
||||
return (
|
||||
<div className="commander-hero">
|
||||
{card.image_uri ? (
|
||||
<img
|
||||
className="commander-hero-img"
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
onClick={() => open(card)}
|
||||
{...handlers(card)}
|
||||
/>
|
||||
) : (
|
||||
<div className="commander-hero-noimg">{card.name}</div>
|
||||
)}
|
||||
<div className="commander-hero-info">
|
||||
<span className="commander-hero-label">Commander</span>
|
||||
<span className="commander-hero-name">{card.name}</span>
|
||||
<span className="commander-hero-type">{card.type_line}</span>
|
||||
<ColorPips mask={card.color_identity} />
|
||||
</div>
|
||||
<IconButton icon={Icons.close} label="Clear commander" onClick={onClear} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
|
||||
// before actually dropping out of the deck — same grace-period pattern as
|
||||
// the Library tab, so a mistaken removal can be undone with +.
|
||||
@@ -289,10 +428,8 @@ export function DeckView() {
|
||||
|
||||
const owned = contents.filter((e) => e.quantity_owned > 0);
|
||||
const unowned = contents.filter((e) => e.quantity_owned === 0);
|
||||
const commanderName =
|
||||
deck?.commander_oracle_id &&
|
||||
(contents.find((e) => e.oracle_id === deck.commander_oracle_id)?.name ??
|
||||
deck.commander_oracle_id);
|
||||
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
|
||||
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
||||
|
||||
function deckTiles(entries: DeckEntry[]) {
|
||||
return (
|
||||
@@ -409,45 +546,48 @@ export function DeckView() {
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<ul className="result-list">
|
||||
<ul className="result-list deck-list">
|
||||
{decks.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
style={d.id === selected ? { background: "var(--accent-subtle)" } : undefined}
|
||||
onClick={() => setSelected(d.id)}
|
||||
>
|
||||
<span className="card-name">{d.name}</span>
|
||||
{d.format && <span className="deck-chip">{d.format}</span>}
|
||||
<span className="spacer" />
|
||||
<span className="row-actions">
|
||||
<IconButton
|
||||
icon={Icons.pencil}
|
||||
label="Rename deck"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogError(null);
|
||||
setDialog({ kind: "rename", deck: d });
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={Icons.copy}
|
||||
label="Duplicate deck"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogError(null);
|
||||
setDialog({ kind: "duplicate", deck: d });
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={Icons.trash}
|
||||
label="Delete deck"
|
||||
danger
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialog({ kind: "delete", deck: d });
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<div className="deck-list-row">
|
||||
<span className="card-name">{d.name}</span>
|
||||
</div>
|
||||
<div className="deck-list-row deck-list-actions-row">
|
||||
{d.format ? <span className="deck-chip">{d.format}</span> : <span />}
|
||||
<span className="row-actions">
|
||||
<IconButton
|
||||
icon={Icons.pencil}
|
||||
label="Rename deck"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogError(null);
|
||||
setDialog({ kind: "rename", deck: d });
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={Icons.copy}
|
||||
label="Duplicate deck"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogError(null);
|
||||
setDialog({ kind: "duplicate", deck: d });
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={Icons.trash}
|
||||
label="Delete deck"
|
||||
danger
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialog({ kind: "delete", deck: d });
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{decks.length === 0 && (
|
||||
@@ -500,6 +640,45 @@ export function DeckView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
||||
<CommanderHero card={commanderEntry} onClear={clearCommander} />
|
||||
)}
|
||||
|
||||
{contents.length > 0 && (
|
||||
<div className="deck-stats">
|
||||
<div className="deck-stats-head">Deck analysis</div>
|
||||
<div className="deck-stats-body">
|
||||
<div className="deck-stats-numbers">
|
||||
<div className="stat-tiles-row">
|
||||
<StatTile value={deckStats.totalCards} label="cards" />
|
||||
<StatTile value={deckStats.uniqueCards} label="unique" />
|
||||
<StatTile value={deckStats.landCount} label="lands" />
|
||||
<StatTile value={deckStats.avgManaValue.toFixed(2)} label="avg MV" />
|
||||
</div>
|
||||
<div className="deck-stats-curve-label">Color identity</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.colorCounts}
|
||||
colorlessCount={deckStats.colorlessCount}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana production</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaProduction}
|
||||
colorlessCount={deckStats.colorlessProduction}
|
||||
/>
|
||||
<div className="deck-stats-curve-label">Mana consumption</div>
|
||||
<ColorDistributionBar
|
||||
colorCounts={deckStats.manaConsumption}
|
||||
colorlessCount={deckStats.colorlessConsumption}
|
||||
/>
|
||||
</div>
|
||||
<div className="deck-stats-curve">
|
||||
<div className="deck-stats-curve-label">Mana curve (nonland)</div>
|
||||
<ManaCurve buckets={deckStats.curve} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
||||
<div className="deck-meta">
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
@@ -515,15 +694,13 @@ export function DeckView() {
|
||||
|
||||
{COMMANDER_FORMATS.includes(deck.format) && (
|
||||
<div className="commander-box">
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<span className="muted">Commander:</span>
|
||||
<strong>{commanderName || "none"}</strong>
|
||||
{commanderName && (
|
||||
<IconButton icon={Icons.close} label="Clear commander" onClick={clearCommander} />
|
||||
)}
|
||||
</div>
|
||||
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
||||
<CardSearchBox
|
||||
placeholder="Search to set the commander…"
|
||||
placeholder={
|
||||
commanderEntry
|
||||
? "Search to change the commander…"
|
||||
: "Search to set the commander…"
|
||||
}
|
||||
value={cmdQuery}
|
||||
onChange={setCmdQuery}
|
||||
results={cmdResults}
|
||||
|
||||
Reference in New Issue
Block a user