diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1a6bd45
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+package-lock.json binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e7ea1e8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,117 @@
+## Node
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.pnp.*
diff --git a/Gruntfile.js b/Gruntfile.js
new file mode 100644
index 0000000..b8c8a77
--- /dev/null
+++ b/Gruntfile.js
@@ -0,0 +1,123 @@
+const path = require("path");
+
+module.exports = grunt => {
+ grunt.initConfig({
+ pkg: grunt.file.readJSON("package.json"),
+ clean: {
+ default: ["dist/"],
+ },
+ copy: {
+ html: {
+ files: [{expand: true, cwd: "src/main/", src: "**/*.html", dest: "dist/"}]
+ },
+ css: {
+ files: [{expand: true, cwd: "src/main/", src: "**/*.css", dest: "dist/"}]
+ },
+ },
+ focus: {
+ dev: {
+ include: ["css", "html", "js", "link"],
+ },
+ },
+ replace: {
+ dev: {
+ src: ["./dist/*.html", "./dist/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>+" + new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")
+ }
+ ],
+ overwrite: true
+ },
+ deploy: {
+ src: ["./dist/*.html", "./dist/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>"
+ }
+ ],
+ overwrite: true
+ },
+ },
+ webpack: {
+ options: {
+ entry: "./src/main/js/index.js",
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ exclude: /node_modules/,
+ },
+ ],
+ },
+ resolve: {
+ extensions: [".js"],
+ },
+ output: {
+ filename: "bundle.js",
+ path: path.resolve(__dirname, "dist/"),
+ },
+ },
+ dev: {
+ mode: "development",
+ devtool: "inline-source-map",
+ },
+ deploy: {
+ mode: "production",
+ },
+ },
+ watch: {
+ css: {
+ files: ["src/main/**/*.css"],
+ tasks: ["copy:css"],
+ },
+ html: {
+ files: ["src/main/**/*.html"],
+ tasks: ["copy:html"],
+ },
+ js: {
+ files: ["src/main/**/*.js"],
+ tasks: ["webpack:dev", "replace:dev"],
+ },
+ link: {
+ files: ["node_modules/@fwdekker/*/dist/**"],
+ tasks: ["webpack:dev", "replace:dev"],
+ },
+ },
+ });
+
+ grunt.loadNpmTasks("grunt-contrib-clean");
+ grunt.loadNpmTasks("grunt-contrib-copy");
+ grunt.loadNpmTasks("grunt-contrib-watch");
+ grunt.loadNpmTasks("grunt-focus");
+ grunt.loadNpmTasks("grunt-text-replace");
+ grunt.loadNpmTasks("grunt-webpack");
+
+ grunt.registerTask("dev", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:html",
+ "copy:css",
+ // Compile
+ "webpack:dev",
+ // Post
+ "replace:dev"
+ ]);
+ grunt.registerTask("dev:server", ["dev", "focus:dev"]);
+ grunt.registerTask("deploy", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:html",
+ "copy:css",
+ // Compile JS
+ "webpack:deploy",
+ // Post
+ "replace:deploy"
+ ]);
+
+ grunt.registerTask("default", ["dev"]);
+};
diff --git a/README.md b/README.md
index c1e62c7..1d5a49f 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,30 @@
# Dice
Given a set of dice, calculates the probability density graph of each value that can be rolled.
+
+## Development
+### Requirements
+* [npm](https://www.npmjs.com/)
+
+### Setting up
+```shell script
+# Install dependencies (only needed once)
+$> npm ci
+```
+
+### Building
+```shell script
+# Build the template in `dist/` for development
+$> npm run dev
+# Same as above, but automatically rerun it whenever files are changed
+$> npm run dev:server
+# Build the template in `dist/` for deployment
+$> npm run deploy
+```
+
+### Publishing
+```shell script
+# Log in to npm
+$> npm login
+# Push to npm
+$> npm publish --access public
+```
diff --git a/index.html b/index.html
deleted file mode 100644
index ebb3f01..0000000
--- a/index.html
+++ /dev/null
@@ -1,310 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Dice probabilities | FWDekker
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..889b318
Binary files /dev/null and b/package-lock.json differ
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..aeb946e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "dice",
+ "version": "1.0.9",
+ "description": "Calculates the probability of rolling a value given a combination of dice.",
+ "author": "Felix W. Dekker",
+ "browser": "dist/bundle.js",
+ "repository": {
+ "type": "git",
+ "url": "git@git.fwdekker.com:FWDekker/dice.git"
+ },
+ "private": true,
+ "scripts": {
+ "clean": "grunt clean",
+ "dev": "grunt dev",
+ "dev:server": "grunt dev:server",
+ "deploy": "grunt deploy"
+ },
+ "dependencies": {
+ "@fwdekker/template": "^0.0.13",
+ "chart.js": "^2.9.3"
+ },
+ "devDependencies": {
+ "grunt": "^1.1.0",
+ "grunt-cli": "^1.3.2",
+ "grunt-contrib-clean": "^2.0.0",
+ "grunt-contrib-copy": "^1.0.0",
+ "grunt-contrib-watch": "^1.1.0",
+ "grunt-focus": "^1.0.0",
+ "grunt-text-replace": "^0.4.0",
+ "grunt-webpack": "^3.1.3",
+ "webpack": "^4.42.1",
+ "webpack-cli": "^3.3.11"
+ }
+}
diff --git a/src/main/index.html b/src/main/index.html
new file mode 100644
index 0000000..e798406
--- /dev/null
+++ b/src/main/index.html
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+ Dice probabilities | FWDekker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/js/index.js b/src/main/js/index.js
new file mode 100644
index 0000000..7005b0f
--- /dev/null
+++ b/src/main/js/index.js
@@ -0,0 +1,238 @@
+import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template";
+import Chart from "chart.js"
+
+
+//////
+///
+/// Helper functions
+///
+//////
+
+const repeat = (value, length) => {
+ const zeroArray = [];
+ for (let i = 0; i < length; i++)
+ zeroArray.push(value);
+ return zeroArray;
+};
+
+const rangeExclusive = (from, to) => {
+ const rangeArray = [];
+ for (let i = from; i < to; i++)
+ rangeArray.push(i);
+ return rangeArray;
+};
+
+const rangeInclusive = (from, to) => {
+ const rangeArray = rangeExclusive(from, to);
+ rangeArray.push(to);
+ return rangeArray;
+};
+
+const iterateNodeList = (nodeList, fun) => {
+ for (let i = 0; i < nodeList.length; i++) {
+ const node = nodeList.item(i);
+ fun(node);
+ }
+};
+
+
+//////
+///
+/// Template
+///
+//////
+
+doAfterLoad(() => {
+ $("#nav").appendChild(nav());
+ $("#header").appendChild(header({
+ title: "Dice",
+ description: "Calculate the probability of rolling a value given a combination of dice"
+ }));
+ $("#footer").appendChild(footer({
+ author: "Felix W. Dekker",
+ authorURL: "https://fwdekker.com/",
+ license: "MIT License",
+ licenseURL: "https://git.fwdekker.com/FWDekker/dice/src/branch/master/LICENSE",
+ vcs: "git",
+ vcsURL: "https://git.fwdekker.com/FWDekker/dice/",
+ version: "v%%VERSION_NUMBER%%"
+ }));
+ $("main").style.display = null;
+});
+
+
+//////
+///
+/// Input
+///
+//////
+
+const inputTable = {};
+
+
+// Functions
+inputTable.getTable = () => $("#dieSettings tbody");
+
+inputTable.dieRowCount = () => inputTable.getTable().querySelectorAll(".dieEyes").length;
+
+inputTable.highestDieRowIndex = () => {
+ const table = inputTable.getTable();
+
+ let highestDieRowIndex = -1;
+ iterateNodeList(table.getElementsByTagName("tr"), (node) => {
+ if ("index" in node.dataset)
+ highestDieRowIndex = Math.max(highestDieRowIndex, +node.dataset.index);
+ });
+
+ return highestDieRowIndex;
+};
+
+inputTable.addDieRow = () => {
+ const createNumberInput = (index, className, value) => {
+ const input = document.createElement("input");
+ input.id = className + index;
+ input.className = className;
+ input.type = "number";
+ input.min = "1";
+ input.step = "1";
+ input.value = value;
+ input.addEventListener("keypress", (e) => {
+ if (e.key === "Enter")
+ outputChart.updateProbGraph();
+ });
+ input.focus();
+ return input;
+ };
+
+ const createRemoveLink = (index, className) => {
+ const link = document.createElement("button");
+ link.id = className + index;
+ link.className = className + " button-clear";
+ link.type = "button";
+ link.innerHTML = "Remove";
+ link.onclick = (() => inputTable.removeDieRow(index));
+ return link;
+ };
+
+
+ const table = inputTable.getTable();
+ const newIndex = inputTable.highestDieRowIndex() + 1;
+
+ const row = table.insertRow(inputTable.dieRowCount());
+ row.dataset.index = "" + newIndex;
+
+ row.insertCell().appendChild(createNumberInput(newIndex, "dieEyes", 6));
+ row.insertCell().appendChild(createNumberInput(newIndex, "dieCount", 2));
+ row.insertCell().appendChild(createRemoveLink(newIndex, "dieRemove"));
+};
+
+inputTable.removeDieRow = index => {
+ if (inputTable.highestDieRowIndex() > 0) {
+ const table = inputTable.getTable();
+ const row = table.querySelector("tr[data-index=\"" + index + "\"]");
+ row.parentElement.removeChild(row);
+ }
+};
+
+inputTable.getDice = () => {
+ const dice = [];
+
+ const eyesInputs = document.getElementsByClassName("dieEyes");
+ const countInputs = document.getElementsByClassName("dieCount");
+ for (let i = 0; i < eyesInputs.length; i++) {
+ const count = parseInt(countInputs.item(i).value);
+
+ for (let j = 0; j < count; j++)
+ dice.push(parseInt(eyesInputs.item(i).value));
+ }
+
+ return dice;
+};
+
+
+// Init
+doAfterLoad(() => {
+ const button = $("#addDieRowButton");
+ button.onclick = inputTable.addDieRow;
+ button.click()
+});
+
+
+//////
+///
+/// Output
+///
+//////
+
+const outputChart = {};
+let probChart;
+
+
+// Functions
+outputChart.calculateDiceFrequencies = dice => {
+ if (dice.length === 0)
+ return [];
+
+
+ // Roll dice
+ let rollFreqs = [0].concat(repeat(1, dice[0]));
+ dice.slice(1).forEach(die => {
+ const dieRollFreqs = rollFreqs.concat(repeat(0, die));
+ rollFreqs = repeat(0, dieRollFreqs.length);
+
+ rangeInclusive(1, die).forEach(rollValue => {
+ rangeExclusive(1, dieRollFreqs.length - die).forEach(i => {
+ rollFreqs[rollValue + i] += dieRollFreqs[i];
+ });
+ });
+ });
+ rollFreqs.shift();
+
+
+ // Calculate frequencies
+ const totalRolls = rollFreqs.reduce((a, b) => a + b, 0);
+ rangeExclusive(0, rollFreqs.length).forEach(roll => {
+ rollFreqs[roll] = rollFreqs[roll] / totalRolls;
+ });
+
+
+ return rollFreqs;
+};
+
+outputChart.updateProbGraph = () => {
+ const dice = inputTable.getDice();
+ const rollFreqs = outputChart.calculateDiceFrequencies(dice);
+
+ probChart.data.labels = rangeInclusive(1, rollFreqs.length);
+ probChart.data.datasets = [{
+ data: rollFreqs,
+ backgroundColor: "rgb(0, 51, 204, 0.4)"
+ }];
+ probChart.update();
+};
+
+
+// Init
+doAfterLoad(() => {
+ probChart = new Chart($("#probChart").getContext("2d"), {
+ type: "line",
+ data: {},
+ options: {
+ legend: {
+ display: false
+ },
+ scales: {
+ yAxes: [{
+ display: true,
+ ticks: {
+ suggestedMin: 0
+ }
+ }]
+ }
+ }
+ });
+
+ const submit = $("#submit");
+ submit.onclick = outputChart.updateProbGraph;
+ submit.click()
+});