Gage Rowden
  • Gage Rowden, M.S.
  • Useful Links
  • Projects
  • Publications
  • Utilities
    • ASCII Image Converter
    • L5R Dice Roller
    • Mandelbrot Viewer
    • Harmonograph
    • BMG Formatter
L5R Dice Roller
heroValue = d3.format(".1%")(stats.p)
heroLabel = `${roll}k${keep} target: ${target}`
mean = `${d3.format(".1f")(stats.mean)} ± ${d3.format(".1f")(stats.sd)}`
median = stats.median
min = stats.min
max = stats.max
n = d3.format(",")(stats.n)

Mean ± SD

Median

Minimum

Maximum

Simulated rolls

Distribution of totals
Plot.plot({
  style: {
    backgroundColor: "transparent",
    color: "#fff",
    fontSize: "13px"
  },
  x: {
    label: "Total rolled",
    labelAnchor: "center",
    tickFormat: "d",
    domain: [0, 150]
  },
  y: {
    label: "Share of rolls",
    tickFormat: ".0%",
    grid: true,
    ticks: 5
  },
  color: {
    domain: ["Below target", "Meets target"],
    range: ["#898781", "#3987e5"],
    legend: true
  },
  marks: [
    Plot.rectY(subset, Plot.binX({y: "sum"}, {
      x: "value",
      y: (d) => d.n / stats.n,
      thresholds: bins.thresholds,
      fill: "transparent",
      color: "#888"
    })),
    Plot.areaY(subset, Plot.binX({y: "sum"}, {
      x: "value",
      y: (d) => d.n / stats.n,
      thresholds: bins.thresholds,
      fill: (d) => d.value >= target ? "Meets target" : "Below target",
      fillOpacity: 0.75
    })),
    Plot.lineY(subset, Plot.binX({y: "sum"}, {
      x: "value",
      y: (d) => d.n / stats.n,
      thresholds: bins.thresholds,
      color: "#000"
    })),
    Plot.ruleX([target], {stroke: "currentColor", strokeOpacity: 0.55}),
    Plot.text([`Target ${target}`], {
      x: target,
      frameAnchor: "top",
      textAnchor: "start",
      dx: 5,
      dy: 4,
      fill: "currentColor",
      fillOpacity: 0.72
    }),
    Plot.ruleY([0], {stroke: "currentColor", strokeOpacity: 0.3})
  ]
})
// Only the selected roll/keep pair is plotted.
subset = transpose(counts).filter((d) => +d.roll === roll && +d.keep === keep)
stats = {
  const rows = subset.slice().sort((a, b) => a.value - b.value);
  const n = d3.sum(rows, (d) => d.n);
  const hits = d3.sum(rows.filter((d) => d.value >= target), (d) => d.n);

  // Weighted median: walk the tallies until half the rolls are behind us.
  let seen = 0;
  let median = NaN;
  for (const row of rows) {
    seen += row.n;
    if (seen >= n / 2) {
      median = row.value;
      break;
    }
  }

  // Each row is a tally, so mean and SD must be weighted by n. d3.deviation
  // would treat every distinct total as a single roll and overweight the tails.
  const mean = n ? d3.sum(rows, (d) => d.value * d.n) / n : NaN;
  const sd = n > 1
    ? Math.sqrt(d3.sum(rows, (d) => d.n * (d.value - mean) ** 2) / (n - 1))
    : NaN;

  return {
    n: n,
    hits: hits,
    p: n ? hits / n : 0,
    mean: mean,
    sd: sd,
    median: median,
    min: rows.length ? rows[0].value : NaN,
    max: rows.length ? rows[rows.length - 1].value : NaN
  };
}
// Bin edges are anchored on the target so no bar straddles it: every bar is
// wholly below the target or wholly at/above it, which is what the fill colour
// claims.
bins = {
  const width = Math.max(1, Math.ceil((stats.max - stats.min + 1) / 36));
  const start = target - Math.ceil((target - stats.min) / width) * width;
  return {
    width: width,
    thresholds: d3.range(start, stats.max + width + 1, width)
  };
}
// Follows Quarto's light/dark toggle so the hover tip matches the card.
darkMode = Generators.observe((notify) => {
  const read = () => notify(document.body.classList.contains("quarto-dark"));
  const observer = new MutationObserver(read);
  read();
  observer.observe(document.body, {attributes: true, attributeFilter: ["class"]});
  return () => observer.disconnect();
})
Average Dice Rolls
Improving Rolls vs. Keeps
viewof roll = Inputs.select(d3.range(1, 11), {
  label: "Roll",
  value: 7
})
// Keep can never exceed roll, so the options follow the roll selection.
viewof keep = Inputs.select(d3.range(1, roll + 1), {
  label: "Keep",
  value: Math.min(7, roll)
})
// The range has to be positional - Inputs.number ignores min/max passed as options.
viewof target = Inputs.number([0, 200], {
  label: "Target",
  value: 40,
  step: 1
})