ManaDash logo ManaDash logo ManaDash
  • Home
  • Archetypes
  • Decktypes
  • Players
  • Elo
  • Deck Viewer
  • Cards Stats
  • Card Groups
  • Card Impact
  • Standings
Card Impact
  • Main Content
viewof modelVersion = Inputs.select(models, {
  label: "Model",
  value: "v1",
  format: m => model_labels[m] ?? m
})

viewof minGames = Inputs.number([0, Infinity], {
  label: "Min Games",
  value: 20,
  step: 1
})

viewof sigFilter = Inputs.select(["All", "p < 0.05", "FDR significant"], {
  label: "Significance",
  value: "All"
})

viewof hideSeparated = Inputs.toggle({
  label: "Hide separated fits",
  value: true
})

viewof cardSearch = Inputs.text({
  label: "Search Card",
  placeholder: "Search card...",
  submit: false
})
glmData = transpose(glm_rows)

// The number input yields null when cleared; treat that as no minimum.
filtered = {
  const query = (cardSearch ?? "").trim().toLowerCase();
  const floor = minGames ?? 0;
  return glmData.filter(d =>
    d.model === modelVersion &&
    d.n_games >= floor &&
    (!hideSeparated || !d.separation) &&
    (sigFilter === "All" ||
     (sigFilter === "p < 0.05" && d.sig_raw) ||
     (sigFilter === "FDR significant" && d.sig_fdr)) &&
    (query === "" || String(d.card_name).toLowerCase().includes(query))
  );
}

// Log axis cannot render a zero or negative bound, and a separated fit can push
// the upper bound into the thousands. Clamp for display only.
CI_FLOOR = 0.05
CI_CEIL = 20
estimable = filtered.filter(d => d.coef != null && d.ci_lower != null && d.ci_upper != null)

forestData = estimable
  .slice()
  .sort((a, b) => d3.descending(a.abs_coef, b.abs_coef))
  .slice(0, 25)
  .map(d => ({
    ...d,
    lo: Math.max(CI_FLOOR, Math.min(CI_CEIL, d.ci_lower)),
    hi: Math.max(CI_FLOOR, Math.min(CI_CEIL, d.ci_upper)),
    clipped: d.ci_lower < CI_FLOOR || d.ci_upper > CI_CEIL
  }))
  .sort((a, b) => d3.descending(a.coef, b.coef))

nSigRaw = filtered.filter(d => d.sig_raw).length
nSigFdr = filtered.filter(d => d.sig_fdr).length
nSeparated = glmData.filter(d => d.model === modelVersion && d.separation).length
medianLift = estimable.length ? d3.median(estimable, d => d.lift_pct) : 0

kpi = (label, value, note) => html`<div style="flex: 1; padding: 8px 14px;
    background: var(--bs-tertiary-bg); border-radius: 5px;">
  <div style="font-size: 12px; opacity: 0.7; text-transform: uppercase;
              letter-spacing: 0.03em;">${label}</div>
  <div style="font-size: 24px; font-weight: 700; line-height: 1.2;">${value}</div>
  <div style="font-size: 12px; opacity: 0.6;">${note}</div>
</div>`

pCell = x => x == null
  ? htl.html`<span style="opacity: 0.5;">—</span>`
  : htl.html`<span style="color: ${x < 0.05 ? "var(--bs-success)" : "inherit"};
      font-weight: ${x < 0.05 ? 600 : 400}">${
        x < 0.001 ? x.toExponential(1) : x.toFixed(3)
      }</span>`

liftCell = x => x == null
  ? htl.html`<span style="opacity: 0.5;">—</span>`
  : htl.html`<span style="color: ${
      x > 0 ? "var(--bs-success)" : x < 0 ? "var(--bs-danger)" : "inherit"
    }; font-weight: 600">${x > 0 ? "+" : ""}${x.toFixed(1)}%</span>`

orCell = x => x == null
  ? htl.html`<span style="opacity: 0.5;">—</span>`
  : htl.html`${x.toFixed(2)}`
html`<div style="display: flex; gap: 8px;">
  ${kpi("Cards shown", filtered.length, `of ${n_cards} fitted`)}
  ${kpi("p < 0.05", nSigRaw, "unadjusted")}
  ${kpi("FDR significant", nSigFdr, "Benjamini-Hochberg")}
  ${kpi("Median lift", `${medianLift > 0 ? "+" : ""}${medianLift.toFixed(1)}%`,
        "win rate, current selection")}
  ${kpi("Separated fits", nSeparated, hideSeparated ? "excluded" : "included")}
</div>`
Effect Size: Top 25 Cards by |coefficient| (odds ratio, 95% CI)
forestData.length === 0
  ? html`<p class="text-muted">No estimable cards match the current filters.</p>`
  : Plot.plot({
      height: Math.max(200, forestData.length * 22 + 60),
      marginLeft: 190,
      marginRight: 20,
      style: {fontSize: "12px"},
      x: {
        type: "log",
        label: "Odds ratio (log scale)",
        domain: [CI_FLOOR, CI_CEIL],
        grid: true,
        ticks: [0.1, 0.25, 0.5, 1, 2, 4, 10],
        tickFormat: x => String(x)
      },
      y: {domain: forestData.map(d => d.card_name), label: null},
      color: {
        domain: ["FDR significant", "p < 0.05", "Not significant"],
        range: ["var(--bs-success)", "var(--bs-warning)", "var(--bs-secondary)"],
        legend: true,
        label: null
      },
      marks: [
        Plot.ruleX([1], {stroke: "gray", strokeDasharray: "4,4", strokeOpacity: 0.6}),
        Plot.link(forestData, {
          y: "card_name",
          x1: "lo",
          x2: "hi",
          stroke: d => d.sig_fdr ? "FDR significant"
            : d.sig_raw ? "p < 0.05" : "Not significant",
          strokeWidth: 2,
          strokeOpacity: 0.7
        }),
        Plot.dot(forestData, {
          y: "card_name",
          x: "odds_ratio",
          fill: d => d.sig_fdr ? "FDR significant"
            : d.sig_raw ? "p < 0.05" : "Not significant",
          r: 5,
          stroke: "var(--bs-body-bg)",
          strokeWidth: 1,
          channels: {
            Card: "card_name",
            Games: "n_games",
            OR: d => d.odds_ratio.toFixed(2),
            CI: d => `${d.ci_lower.toFixed(2)} – ${d.ci_upper.toFixed(2)}`,
            p: d => d.p.toExponential(1),
            "CI clipped": d => d.clipped ? "yes" : "no"
          },
          tip: {format: {x: false, y: false, fill: false, r: false}}
        })
      ]
    })
Volcano: Effect vs Evidence
estimable.length === 0
  ? html`<p class="text-muted">No estimable cards match the current filters.</p>`
  : Plot.plot({
      marginLeft: 60,
      marginBottom: 45,
      style: {fontSize: "12px"},
      x: {label: "Coefficient (log odds)", grid: true, nice: true},
      y: {label: "−log₁₀(p)", grid: true, nice: true},
      r: {range: [2, 12]},
      color: {
        domain: ["FDR significant", "p < 0.05", "Not significant"],
        range: ["var(--bs-success)", "var(--bs-warning)", "var(--bs-secondary)"],
        legend: true,
        label: null
      },
      marks: [
        Plot.ruleX([0], {stroke: "gray", strokeDasharray: "4,4", strokeOpacity: 0.6}),
        Plot.ruleY([-Math.log10(0.05)], {
          stroke: "gray", strokeDasharray: "2,3", strokeOpacity: 0.5
        }),
        Plot.dot(estimable, {
          x: "coef",
          y: "neg_log10_p",
          r: "n_games",
          fill: d => d.sig_fdr ? "FDR significant"
            : d.sig_raw ? "p < 0.05" : "Not significant",
          fillOpacity: 0.75,
          stroke: "var(--bs-body-bg)",
          strokeWidth: 0.5,
          channels: {
            Card: "card_name",
            Games: "n_games",
            Lift: d => d.lift_pct == null ? "—" : `${d.lift_pct.toFixed(1)}%`
          },
          tip: {format: {x: ".3f", y: false, r: false, fill: false}}
        })
      ]
    })
Detailed Estimates (pooled across all seasons)
Inputs.table(filtered, {
  columns: [
    "card_name", "n_games", "coef", "se", "odds_ratio",
    "ci_lower", "ci_upper", "p", "p_adj", "lift_pct", "confidence", "best_model"
  ],
  header: {
    card_name: "Card Name",
    n_games: "Games",
    coef: "Coef",
    se: "SE",
    odds_ratio: "OR",
    ci_lower: "CI low",
    ci_upper: "CI high",
    p: "p",
    p_adj: "p (FDR)",
    lift_pct: "Win rate lift",
    confidence: "Confidence",
    best_model: "Best AIC"
  },
  format: {
    coef: x => x == null ? "—" : x.toFixed(3),
    se: x => x == null ? "—" : x.toFixed(3),
    odds_ratio: orCell,
    ci_lower: orCell,
    ci_upper: orCell,
    p: pCell,
    p_adj: pCell,
    lift_pct: liftCell
  },
  align: {
    n_games: "right", coef: "right", se: "right", odds_ratio: "right",
    ci_lower: "right", ci_upper: "right", p: "right", p_adj: "right",
    lift_pct: "right"
  },
  sort: "abs_coef",
  reverse: true,
  rows: 20,
  layout: "auto"
})
 

ManaDash — Vintage Cube Analysis