Repository: shapiromatron/hawc (language: python)

Issue:
data pivot - add columns to the right of the figure
Add new option to add content to the right of the plot in a data pivot.


Check an item "to right":

![Screenshot 2024-02-06 at 6 02 09 PM](https://github.com/shapiromatron/hawc/assets/999952/569c2203-c8f0-46a2-b4d4-4ae261a6c619)

![image](https://github.com/shapiromatron/hawc/assets/999952/393f7ab8-40b3-4d65-9868-dcddde17d157)

![image](https://github.com/shapiromatron/hawc/assets/999952/1f39fbb3-af3c-4168-aeef-14ea36e75e75)

Related work in #991 adds calculated columns as a new option, to compose multiple columns in a dataset to a new column.
data pivot - add columns to the right of the figure
Add new option to add content to the right of the plot in a data pivot.


Check an item "to right":

![Screenshot 2024-02-06 at 6 02 09 PM](https://github.com/shapiromatron/hawc/assets/999952/569c2203-c8f0-46a2-b4d4-4ae261a6c619)

![image](https://github.com/shapiromatron/hawc/assets/999952/393f7ab8-40b3-4d65-9868-dcddde17d157)

![image](https://github.com/shapiromatron/hawc/assets/999952/1f39fbb3-af3c-4168-aeef-14ea36e75e75)

Related work in #991 adds calculated columns as a new option, to compose multiple columns in a dataset to a new column.


Discussion / hints:

Fix patch:
diff --git a/frontend/.eslintignore b/frontend/.eslintignore
index aae872fb6c..448c6f4cf8 100644
--- a/frontend/.eslintignore
+++ b/frontend/.eslintignore
@@ -1,2 +1,2 @@
-shared/parsers/query.js
+shared/parsers
 uswds/node_modules
diff --git a/frontend/package.json b/frontend/package.json
index 14d4ea9cf3..80db775cee 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,7 +12,8 @@
         "lint": "eslint --cache .",
         "format": "eslint --cache --fix .",
         "test": "./node_modules/mochapack/bin/mochapack \"tests/**/*.js\"",
-        "test-windows": "node_modules\\.bin\\mochapack \"tests/**/*.js\""
+        "test-windows": "node_modules\\.bin\\mochapack \"tests/**/*.js\"",
+        "generate-parsers": "peggy ./shared/parsers/query.pegjs && peggy ./shared/parsers/format.pegjs"
     },
     "repository": {
         "type": "git",
@@ -45,6 +46,7 @@
         "express": "^4.18.2",
         "mocha": "^10.2.0",
         "mochapack": "^2.1.4",
+        "peggy": "^4.0.2",
         "prettier": "^1.19.1",
         "rimraf": "^5.0.1",
         "style-loader": "^3.3.3",
diff --git a/frontend/shared/parsers/format.js b/frontend/shared/parsers/format.js
new file mode 100644
index 0000000000..7b35d8c0b2
--- /dev/null
+++ b/frontend/shared/parsers/format.js
@@ -0,0 +1,1262 @@
+// @generated by Peggy 4.0.2.
+//
+// https://peggyjs.org/
+
+"use strict";
+
+
+function peg$subclass(child, parent) {
+  function C() { this.constructor = child; }
+  C.prototype = parent.prototype;
+  child.prototype = new C();
+}
+
+function peg$SyntaxError(message, expected, found, location) {
+  var self = Error.call(this, message);
+  // istanbul ignore next Check is a necessary evil to support older environments
+  if (Object.setPrototypeOf) {
+    Object.setPrototypeOf(self, peg$SyntaxError.prototype);
+  }
+  self.expected = expected;
+  self.found = found;
+  self.location = location;
+  self.name = "SyntaxError";
+  return self;
+}
+
+peg$subclass(peg$SyntaxError, Error);
+
+function peg$padEnd(str, targetLength, padString) {
+  padString = padString || " ";
+  if (str.length > targetLength) { return str; }
+  targetLength -= str.length;
+  padString += padString.repeat(targetLength);
+  return str + padString.slice(0, targetLength);
+}
+
+peg$SyntaxError.prototype.format = function(sources) {
+  var str = "Error: " + this.message;
+  if (this.location) {
+    var src = null;
+    var k;
+    for (k = 0; k < sources.length; k++) {
+      if (sources[k].source === this.location.source) {
+        src = sources[k].text.split(/\r\n|\n|\r/g);
+        break;
+      }
+    }
+    var s = this.location.start;
+    var offset_s = (this.location.source && (typeof this.location.source.offset === "function"))
+      ? this.location.source.offset(s)
+      : s;
+    var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column;
+    if (src) {
+      var e = this.location.end;
+      var filler = peg$padEnd("", offset_s.line.toString().length, ' ');
+      var line = src[s.line - 1];
+      var last = s.line === e.line ? e.column : line.length + 1;
+      var hatLen = (last - s.column) || 1;
+      str += "\n --> " + loc + "\n"
+          + filler + " |\n"
+          + offset_s.line + " | " + line + "\n"
+          + filler + " | " + peg$padEnd("", s.column - 1, ' ')
+          + peg$padEnd("", hatLen, "^");
+    } else {
+      str += "\n at " + loc;
+    }
+  }
+  return str;
+};
+
+peg$SyntaxError.buildMessage = function(expected, found) {
+  var DESCRIBE_EXPECTATION_FNS = {
+    literal: function(expectation) {
+      return "\"" + literalEscape(expectation.text) + "\"";
+    },
+
+    class: function(expectation) {
+      var escapedParts = expectation.parts.map(function(part) {
+        return Array.isArray(part)
+          ? classEscape(part[0]) + "-" + classEscape(part[1])
+          : classEscape(part);
+      });
+
+      return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]";
+    },
+
+    any: function() {
+      return "any character";
+    },
+
+    end: function() {
+      return "end of input";
+    },
+
+    other: function(expectation) {
+      return expectation.description;
+    }
+  };
+
+  function hex(ch) {
+    return ch.charCodeAt(0).toString(16).toUpperCase();
+  }
+
+  function literalEscape(s) {
+    return s
+      .replace(/\\/g, "\\\\")
+      .replace(/"/g,  "\\\"")
+      .replace(/\0/g, "\\0")
+      .replace(/\t/g, "\\t")
+      .replace(/\n/g, "\\n")
+      .replace(/\r/g, "\\r")
+      .replace(/[\x00-\x0F]/g,          function(ch) { return "\\x0" + hex(ch); })
+      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x"  + hex(ch); });
+  }
+
+  function classEscape(s) {
+    return s
+      .replace(/\\/g, "\\\\")
+      .replace(/\]/g, "\\]")
+      .replace(/\^/g, "\\^")
+      .replace(/-/g,  "\\-")
+      .replace(/\0/g, "\\0")
+      .replace(/\t/g, "\\t")
+      .replace(/\n/g, "\\n")
+      .replace(/\r/g, "\\r")
+      .replace(/[\x00-\x0F]/g,          function(ch) { return "\\x0" + hex(ch); })
+      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x"  + hex(ch); });
+  }
+
+  function describeExpectation(expectation) {
+    return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation);
+  }
+
+  function describeExpected(expected) {
+    var descriptions = expected.map(describeExpectation);
+    var i, j;
+
+    descriptions.sort();
+
+    if (descriptions.length > 0) {
+      for (i = 1, j = 1; i < descriptions.length; i++) {
+        if (descriptions[i - 1] !== descriptions[i]) {
+          descriptions[j] = descriptions[i];
+          j++;
+        }
+      }
+      descriptions.length = j;
+    }
+
+    switch (descriptions.length) {
+      case 1:
+        return descriptions[0];
+
+      case 2:
+        return descriptions[0] + " or " + descriptions[1];
+
+      default:
+        return descriptions.slice(0, -1).join(", ")
+          + ", or "
+          + descriptions[descriptions.length - 1];
+    }
+  }
+
+  function describeFound(found) {
+    return found ? "\"" + literalEscape(found) + "\"" : "end of input";
+  }
+
+  return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found.";
+};
+
+function peg$parse(input, options) {
+  options = options !== undefined ? options : {};
+
+  var peg$FAILED = {};
+  var peg$source = options.grammarSource;
+
+  var peg$startRuleFunctions = { Format: peg$parseFormat };
+  var peg$startRuleFunction = peg$parseFormat;
+
+  var peg$c0 = "?";
+  var peg$c1 = ":";
+  var peg$c2 = "match(";
+  var peg$c3 = ",";
+  var peg$c4 = ")";
+  var peg$c5 = "exists(";
+  var peg$c6 = "${";
+  var peg$c7 = "}";
+  var peg$c8 = "\"";
+  var peg$c9 = "round(";
+  var peg$c10 = "round";
+  var peg$c11 = "\\";
+
+  var peg$r0 = /^[0-9]/;
+
+  var peg$e0 = peg$literalExpectation("?", false);
+  var peg$e1 = peg$literalExpectation(":", false);
+  var peg$e2 = peg$literalExpectation("match(", false);
+  var peg$e3 = peg$literalExpectation(",", false);
+  var peg$e4 = peg$literalExpectation(")", false);
+  var peg$e5 = peg$literalExpectation("exists(", false);
+  var peg$e6 = peg$literalExpectation("${", false);
+  var peg$e7 = peg$literalExpectation("}", false);
+  var peg$e8 = peg$literalExpectation("\"", false);
+  var peg$e9 = peg$classExpectation([["0", "9"]], false, false);
+  var peg$e10 = peg$literalExpectation("round(", false);
+  var peg$e11 = peg$anyExpectation();
+  var peg$e12 = peg$literalExpectation("round", false);
+  var peg$e13 = peg$literalExpectation("\\", false);
+
+  var peg$f0 = function(c, t1, t2) { return c ? t1 : t2; };
+  var peg$f1 = function(sections) { return sections.join(""); };
+  var peg$f2 = function(sections) { return sections.join(""); };
+  var peg$f3 = function(id, val) { return options.getValue(id) == val; };
+  var peg$f4 = function(id) { return options.getValue(id) != null && options.getValue(id) != ""; };
+  var peg$f5 = function(identifier) { return options.getValue(identifier); };
+  var peg$f6 = function(chars) { return chars.join(""); };
+  var peg$f7 = function(chars) { return chars.join(""); };
+  var peg$f8 = function(chars) { return chars.join(""); };
+  var peg$f9 = function(chars) { return chars.join(""); };
+  var peg$f10 = function(chars) { return chars.join(""); };
+  var peg$f11 = function(integer) { return parseInt(integer, 10); };
+  var peg$f12 = function(id, digits) {
+    var val = parseFloat(options.getValue(id));
+    return isFinite(val) ? val.toFixed(digits) : "";
+};
+  var peg$currPos = options.peg$currPos | 0;
+  var peg$savedPos = peg$currPos;
+  var peg$posDetailsCache = [{ line: 1, column: 1 }];
+  var peg$maxFailPos = peg$currPos;
+  var peg$maxFailExpected = options.peg$maxFailExpected || [];
+  var peg$silentFails = options.peg$silentFails | 0;
+
+  var peg$result;
+
+  if (options.startRule) {
+    if (!(options.startRule in peg$startRuleFunctions)) {
+      throw new Error("Can't start parsing from rule \"" + options.startRule + "\".");
+    }
+
+    peg$startRuleFunction = peg$startRuleFunctions[options.startRule];
+  }
+
+  function text() {
+    return input.substring(peg$savedPos, peg$currPos);
+  }
+
+  function offset() {
+    return peg$savedPos;
+  }
+
+  function range() {
+    return {
+      source: peg$source,
+      start: peg$savedPos,
+      end: peg$currPos
+    };
+  }
+
+  function location() {
+    return peg$computeLocation(peg$savedPos, peg$currPos);
+  }
+
+  function expected(description, location) {
+    location = location !== undefined
+      ? location
+      : peg$computeLocation(peg$savedPos, peg$currPos);
+
+    throw peg$buildStructuredError(
+      [peg$otherExpectation(description)],
+      input.substring(peg$savedPos, peg$currPos),
+      location
+    );
+  }
+
+  function error(message, location) {
+    location = location !== undefined
+      ? location
+      : peg$computeLocation(peg$savedPos, peg$currPos);
+
+    throw peg$buildSimpleError(message, location);
+  }
+
+  function peg$literalExpectation(text, ignoreCase) {
+    return { type: "literal", text: text, ignoreCase: ignoreCase };
+  }
+
+  function peg$classExpectation(parts, inverted, ignoreCase) {
+    return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase };
+  }
+
+  function peg$anyExpectation() {
+    return { type: "any" };
+  }
+
+  function peg$endExpectation() {
+    return { type: "end" };
+  }
+
+  function peg$otherExpectation(description) {
+    return { type: "other", description: description };
+  }
+
+  function peg$computePosDetails(pos) {
+    var details = peg$posDetailsCache[pos];
+    var p;
+
+    if (details) {
+      return details;
+    } else {
+      if (pos >= peg$posDetailsCache.length) {
+        p = peg$posDetailsCache.length - 1;
+      } else {
+        p = pos;
+        while (!peg$posDetailsCache[--p]) {}
+      }
+
+      details = peg$posDetailsCache[p];
+      details = {
+        line: details.line,
+        column: details.column
+      };
+
+      while (p < pos) {
+        if (input.charCodeAt(p) === 10) {
+          details.line++;
+          details.column = 1;
+        } else {
+          details.column++;
+        }
+
+        p++;
+      }
+
+      peg$posDetailsCache[pos] = details;
+
+      return details;
+    }
+  }
+
+  function peg$computeLocation(startPos, endPos, offset) {
+    var startPosDetails = peg$computePosDetails(startPos);
+    var endPosDetails = peg$computePosDetails(endPos);
+
+    var res = {
+      source: peg$source,
+      start: {
+        offset: startPos,
+        line: startPosDetails.line,
+        column: startPosDetails.column
+      },
+      end: {
+        offset: endPos,
+        line: endPosDetails.line,
+        column: endPosDetails.column
+      }
+    };
+    if (offset && peg$source && (typeof peg$source.offset === "function")) {
+      res.start = peg$source.offset(res.start);
+      res.end = peg$source.offset(res.end);
+    }
+    return res;
+  }
+
+  function peg$fail(expected) {
+    if (peg$currPos < peg$maxFailPos) { return; }
+
+    if (peg$currPos > peg$maxFailPos) {
+      peg$maxFailPos = peg$currPos;
+      peg$maxFailExpected = [];
+    }
+
+    peg$maxFailExpected.push(expected);
+  }
+
+  function peg$buildSimpleError(message, location) {
+    return new peg$SyntaxError(message, null, null, location);
+  }
+
+  function peg$buildStructuredError(expected, found, location) {
+    return new peg$SyntaxError(
+      peg$SyntaxError.buildMessage(expected, found),
+      expected,
+      found,
+      location
+    );
+  }
+
+  function peg$parseFormat() {
+    var s0;
+
+    s0 = peg$parseTernary();
+    if (s0 === peg$FAILED) {
+      s0 = peg$parseStatement();
+    }
+
+    return s0;
+  }
+
+  function peg$parseTernary() {
+    var s0, s1, s2, s3, s4, s5;
+
+    s0 = peg$currPos;
+    s1 = peg$parseCondition();
+    if (s1 !== peg$FAILED) {
+      if (input.charCodeAt(peg$currPos) === 63) {
+        s2 = peg$c0;
+        peg$currPos++;
+      } else {
+        s2 = peg$FAILED;
+        if (peg$silentFails === 0) { peg$fail(peg$e0); }
+      }
+      if (s2 !== peg$FAILED) {
+        s3 = peg$parseTernaryTrue();
+        if (s3 !== peg$FAILED) {
+          if (input.charCodeAt(peg$currPos) === 58) {
+            s4 = peg$c1;
+            peg$currPos++;
+          } else {
+            s4 = peg$FAILED;
+            if (peg$silentFails === 0) { peg$fail(peg$e1); }
+          }
+          if (s4 !== peg$FAILED) {
+            s5 = peg$parseTernaryFalse();
+            if (s5 !== peg$FAILED) {
+              peg$savedPos = s0;
+              s0 = peg$f0(s1, s3, s5);
+            } else {
+              peg$currPos = s0;
+              s0 = peg$FAILED;
+            }
+          } else {
+            peg$currPos = s0;
+            s0 = peg$FAILED;
+          }
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    } else {
+      peg$currPos = s0;
+      s0 = peg$FAILED;
+    }
+
+    return s0;
+  }
+
+  function peg$parseTernaryTrue() {
+    var s0;
+
+    s0 = peg$parseTernary();
+    if (s0 === peg$FAILED) {
+      s0 = peg$parseTernaryStatement();
+    }
+
+    return s0;
+  }
+
+  function peg$parseTernaryFalse() {
+    var s0;
+
+    s0 = peg$parseTernary();
+    if (s0 === peg$FAILED) {
+      s0 = peg$parseTernaryStatement();
+    }
+
+    return s0;
+  }
+
+  function peg$parseStatement() {
+    var s0, s1, s2;
+
+    s0 = peg$currPos;
+    s1 = [];
+    s2 = peg$parseSection();
+    while (s2 !== peg$FAILED) {
+      s1.push(s2);
+      s2 = peg$parseSection();
+    }
+    peg$savedPos = s0;
+    s1 = peg$f1(s1);
+    s0 = s1;
+
+    return s0;
+  }
+
+  function peg$parseTernaryStatement() {
+    var s0, s1, s2;
+
+    s0 = peg$currPos;
+    s1 = [];
+    s2 = peg$parseTernarySection();
+    while (s2 !== peg$FAILED) {
+      s1.push(s2);
+      s2 = peg$parseTernarySection();
+    }
+    peg$savedPos = s0;
+    s1 = peg$f2(s1);
+    s0 = s1;
+
+    return s0;
+  }
+
+  function peg$parseSection() {
+    var s0;
+
+    s0 = peg$parseRound();
+    if (s0 === peg$FAILED) {
+      s0 = peg$parsePlaceholder();
+      if (s0 === peg$FAILED) {
+        s0 = peg$parseFiller();
+      }
+    }
+
+    return s0;
+  }
+
+  function peg$parseTernarySection() {
+    var s0;
+
+    s0 = peg$parsePlaceholder();
+    if (s0 === peg$FAILED) {
+      s0 = peg$parseTernaryFiller();
+    }
+
+    return s0;
+  }
+
+  function peg$parseCondition() {
+    var s0;
+
+    s0 = peg$parseMatch();
+    if (s0 === peg$FAILED) {
+      s0 = peg$parseExists();
+    }
+
+    return s0;
+  }
+
+  function peg$parseMatch() {
+    var s0, s1, s2, s3, s4, s5;
+
+    s0 = peg$currPos;
+    if (input.substr(peg$currPos, 6) === peg$c2) {
+      s1 = peg$c2;
+      peg$currPos += 6;
+    } else {
+      s1 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e2); }
+    }
+    if (s1 !== peg$FAILED) {
+      s2 = peg$parseConditionIdentifier();
+      if (s2 !== peg$FAILED) {
+        if (input.charCodeAt(peg$currPos) === 44) {
+          s3 = peg$c3;
+          peg$currPos++;
+        } else {
+          s3 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e3); }
+        }
+        if (s3 !== peg$FAILED) {
+          s4 = peg$parseString();
+          if (s4 === peg$FAILED) {
+            s4 = peg$parseInteger();
+          }
+          if (s4 !== peg$FAILED) {
+            if (input.charCodeAt(peg$currPos) === 41) {
+              s5 = peg$c4;
+              peg$currPos++;
+            } else {
+              s5 = peg$FAILED;
+              if (peg$silentFails === 0) { peg$fail(peg$e4); }
+            }
+            if (s5 !== peg$FAILED) {
+              peg$savedPos = s0;
+              s0 = peg$f3(s2, s4);
+            } else {
+              peg$currPos = s0;
+              s0 = peg$FAILED;
+            }
+          } else {
+            peg$currPos = s0;
+            s0 = peg$FAILED;
+          }
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    } else {
+      peg$currPos = s0;
+      s0 = peg$FAILED;
+    }
+
+    return s0;
+  }
+
+  function peg$parseExists() {
+    var s0, s1, s2, s3;
+
+    s0 = peg$currPos;
+    if (input.substr(peg$currPos, 7) === peg$c5) {
+      s1 = peg$c5;
+      peg$currPos += 7;
+    } else {
+      s1 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e5); }
+    }
+    if (s1 !== peg$FAILED) {
+      s2 = peg$parseConditionIdentifier();
+      if (s2 !== peg$FAILED) {
+        if (input.charCodeAt(peg$currPos) === 41) {
+          s3 = peg$c4;
+          peg$currPos++;
+        } else {
+          s3 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e4); }
+        }
+        if (s3 !== peg$FAILED) {
+          peg$savedPos = s0;
+          s0 = peg$f4(s2);
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    } else {
+      peg$currPos = s0;
+      s0 = peg$FAILED;
+    }
+
+    return s0;
+  }
+
+  function peg$parsePlaceholder() {
+    var s0, s1, s2, s3;
+
+    s0 = peg$currPos;
+    if (input.substr(peg$currPos, 2) === peg$c6) {
+      s1 = peg$c6;
+      peg$currPos += 2;
+    } else {
+      s1 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e6); }
+    }
+    if (s1 !== peg$FAILED) {
+      s2 = peg$parseIdentifier();
+      if (s2 !== peg$FAILED) {
+        if (input.charCodeAt(peg$currPos) === 125) {
+          s3 = peg$c7;
+          peg$currPos++;
+        } else {
+          s3 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e7); }
+        }
+        if (s3 !== peg$FAILED) {
+          peg$savedPos = s0;
+          s0 = peg$f5(s2);
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    } else {
+      peg$currPos = s0;
+      s0 = peg$FAILED;
+    }
+
+    return s0;
+  }
+
+  function peg$parseIdentifier() {
+    var s0, s1, s2;
+
+    s0 = peg$currPos;
+    s1 = [];
+    s2 = peg$parseIdentifierChar();
+    if (s2 !== peg$FAILED) {
+      while (s2 !== peg$FAILED) {
+        s1.push(s2);
+        s2 = peg$parseIdentifierChar();
+      }
+    } else {
+      s1 = peg$FAILED;
+    }
+    if (s1 !== peg$FAILED) {
+      peg$savedPos = s0;
+      s1 = peg$f6(s1);
+    }
+    s0 = s1;
+
+    return s0;
+  }
+
+  function peg$parseConditionIdentifier() {
+    var s0, s1, s2;
+
+    s0 = peg$currPos;
+    s1 = [];
+    s2 = peg$parseConditionIdentifierChar();
+    if (s2 !== peg$FAILED) {
+      while (s2 !== peg$FAILED) {
+        s1.push(s2);
+        s2 = peg$parseConditionIdentifierChar();
+      }
+    } else {
+      s1 = peg$FAILED;
+    }
+    if (s1 !== peg$FAILED) {
+      peg$savedPos = s0;
+      s1 = peg$f7(s1);
+    }
+    s0 = s1;
+
+    return s0;
+  }
+
+  function peg$parseFiller() {
+    var s0, s1, s2;
+
+    s0 = peg$currPos;
+    s1 = [];
+    s2 = peg$parseFillerChar();
+    if (s2 !== peg$FAILED) {
+      while (s2 !== peg$FAILED) {
+        s1.push(s2);
+        s2 = peg$parseFillerChar();
+      }
+    } else {
+      s1 = peg$FAILED;
+    }
+    if (s1 !== peg$FAILED) {
+      peg$savedPos = s0;
+      s1 = peg$f8(s1);
+    }
+    s0 = s1;
+
+    return s0;
+  }
+
+  function peg$parseTernaryFiller() {
+    var s0, s1, s2;
+
+    s0 = peg$currPos;
+    s1 = [];
+    s2 = peg$parseTernaryFillerChar();
+    if (s2 !== peg$FAILED) {
+      while (s2 !== peg$FAILED) {
+        s1.push(s2);
+        s2 = peg$parseTernaryFillerChar();
+      }
+    } else {
+      s1 = peg$FAILED;
+    }
+    if (s1 !== peg$FAILED) {
+      peg$savedPos = s0;
+      s1 = peg$f9(s1);
+    }
+    s0 = s1;
+
+    return s0;
+  }
+
+  function peg$parseString() {
+    var s0, s1, s2, s3;
+
+    s0 = peg$currPos;
+    if (input.charCodeAt(peg$currPos) === 34) {
+      s1 = peg$c8;
+      peg$currPos++;
+    } else {
+      s1 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e8); }
+    }
+    if (s1 !== peg$FAILED) {
+      s2 = [];
+      s3 = peg$parseStringChar();
+      while (s3 !== peg$FAILED) {
+        s2.push(s3);
+        s3 = peg$parseStringChar();
+      }
+      if (input.charCodeAt(peg$currPos) === 34) {
+        s3 = peg$c8;
+        peg$currPos++;
+      } else {
+        s3 = peg$FAILED;
+        if (peg$silentFails === 0) { peg$fail(peg$e8); }
+      }
+      if (s3 !== peg$FAILED) {
+        peg$savedPos = s0;
+        s0 = peg$f10(s2);
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    } else {
+      peg$currPos = s0;
+      s0 = peg$FAILED;
+    }
+
+    return s0;
+  }
+
+  function peg$parseInteger() {
+    var s0, s1, s2;
+
+    s0 = peg$currPos;
+    s1 = [];
+    s2 = input.charAt(peg$currPos);
+    if (peg$r0.test(s2)) {
+      peg$currPos++;
+    } else {
+      s2 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e9); }
+    }
+    if (s2 !== peg$FAILED) {
+      while (s2 !== peg$FAILED) {
+        s1.push(s2);
+        s2 = input.charAt(peg$currPos);
+        if (peg$r0.test(s2)) {
+          peg$currPos++;
+        } else {
+          s2 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e9); }
+        }
+      }
+    } else {
+      s1 = peg$FAILED;
+    }
+    if (s1 !== peg$FAILED) {
+      peg$savedPos = s0;
+      s1 = peg$f11(s1);
+    }
+    s0 = s1;
+
+    return s0;
+  }
+
+  function peg$parseRound() {
+    var s0, s1, s2, s3, s4, s5;
+
+    s0 = peg$currPos;
+    if (input.substr(peg$currPos, 6) === peg$c9) {
+      s1 = peg$c9;
+      peg$currPos += 6;
+    } else {
+      s1 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e10); }
+    }
+    if (s1 !== peg$FAILED) {
+      s2 = peg$parseConditionIdentifier();
+      if (s2 !== peg$FAILED) {
+        if (input.charCodeAt(peg$currPos) === 44) {
+          s3 = peg$c3;
+          peg$currPos++;
+        } else {
+          s3 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e3); }
+        }
+        if (s3 !== peg$FAILED) {
+          s4 = peg$parseInteger();
+          if (s4 !== peg$FAILED) {
+            if (input.charCodeAt(peg$currPos) === 41) {
+              s5 = peg$c4;
+              peg$currPos++;
+            } else {
+              s5 = peg$FAILED;
+              if (peg$silentFails === 0) { peg$fail(peg$e4); }
+            }
+            if (s5 !== peg$FAILED) {
+              peg$savedPos = s0;
+              s0 = peg$f12(s2, s4);
+            } else {
+              peg$currPos = s0;
+              s0 = peg$FAILED;
+            }
+          } else {
+            peg$currPos = s0;
+            s0 = peg$FAILED;
+          }
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    } else {
+      peg$currPos = s0;
+      s0 = peg$FAILED;
+    }
+
+    return s0;
+  }
+
+  function peg$parseIdentifierChar() {
+    var s0, s1, s2;
+
+    s0 = peg$parse_EscapedChar();
+    if (s0 === peg$FAILED) {
+      s0 = peg$currPos;
+      s1 = peg$currPos;
+      peg$silentFails++;
+      if (input.charCodeAt(peg$currPos) === 125) {
+        s2 = peg$c7;
+        peg$currPos++;
+      } else {
+        s2 = peg$FAILED;
+        if (peg$silentFails === 0) { peg$fail(peg$e7); }
+      }
+      peg$silentFails--;
+      if (s2 === peg$FAILED) {
+        s1 = undefined;
+      } else {
+        peg$currPos = s1;
+        s1 = peg$FAILED;
+      }
+      if (s1 !== peg$FAILED) {
+        if (input.length > peg$currPos) {
+          s2 = input.charAt(peg$currPos);
+          peg$currPos++;
+        } else {
+          s2 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e11); }
+        }
+        if (s2 !== peg$FAILED) {
+          s0 = s2;
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    }
+
+    return s0;
+  }
+
+  function peg$parseConditionIdentifierChar() {
+    var s0, s1, s2, s3;
+
+    s0 = peg$parse_EscapedChar();
+    if (s0 === peg$FAILED) {
+      s0 = peg$currPos;
+      s1 = peg$currPos;
+      peg$silentFails++;
+      if (input.charCodeAt(peg$currPos) === 44) {
+        s2 = peg$c3;
+        peg$currPos++;
+      } else {
+        s2 = peg$FAILED;
+        if (peg$silentFails === 0) { peg$fail(peg$e3); }
+      }
+      peg$silentFails--;
+      if (s2 === peg$FAILED) {
+        s1 = undefined;
+      } else {
+        peg$currPos = s1;
+        s1 = peg$FAILED;
+      }
+      if (s1 !== peg$FAILED) {
+        s2 = peg$currPos;
+        peg$silentFails++;
+        if (input.charCodeAt(peg$currPos) === 41) {
+          s3 = peg$c4;
+          peg$currPos++;
+        } else {
+          s3 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e4); }
+        }
+        peg$silentFails--;
+        if (s3 === peg$FAILED) {
+          s2 = undefined;
+        } else {
+          peg$currPos = s2;
+          s2 = peg$FAILED;
+        }
+        if (s2 !== peg$FAILED) {
+          if (input.length > peg$currPos) {
+            s3 = input.charAt(peg$currPos);
+            peg$currPos++;
+          } else {
+            s3 = peg$FAILED;
+            if (peg$silentFails === 0) { peg$fail(peg$e11); }
+          }
+          if (s3 !== peg$FAILED) {
+            s0 = s3;
+          } else {
+            peg$currPos = s0;
+            s0 = peg$FAILED;
+          }
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    }
+
+    return s0;
+  }
+
+  function peg$parseFillerChar() {
+    var s0, s1, s2, s3;
+
+    s0 = peg$parse_EscapedChar();
+    if (s0 === peg$FAILED) {
+      s0 = peg$currPos;
+      s1 = peg$currPos;
+      peg$silentFails++;
+      if (input.substr(peg$currPos, 2) === peg$c6) {
+        s2 = peg$c6;
+        peg$currPos += 2;
+      } else {
+        s2 = peg$FAILED;
+        if (peg$silentFails === 0) { peg$fail(peg$e6); }
+      }
+      peg$silentFails--;
+      if (s2 === peg$FAILED) {
+        s1 = undefined;
+      } else {
+        peg$currPos = s1;
+        s1 = peg$FAILED;
+      }
+      if (s1 !== peg$FAILED) {
+        s2 = peg$currPos;
+        peg$silentFails++;
+        if (input.substr(peg$currPos, 5) === peg$c10) {
+          s3 = peg$c10;
+          peg$currPos += 5;
+        } else {
+          s3 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e12); }
+        }
+        peg$silentFails--;
+        if (s3 === peg$FAILED) {
+          s2 = undefined;
+        } else {
+          peg$currPos = s2;
+          s2 = peg$FAILED;
+        }
+        if (s2 !== peg$FAILED) {
+          if (input.length > peg$currPos) {
+            s3 = input.charAt(peg$currPos);
+            peg$currPos++;
+          } else {
+            s3 = peg$FAILED;
+            if (peg$silentFails === 0) { peg$fail(peg$e11); }
+          }
+          if (s3 !== peg$FAILED) {
+            s0 = s3;
+          } else {
+            peg$currPos = s0;
+            s0 = peg$FAILED;
+          }
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    }
+
+    return s0;
+  }
+
+  function peg$parseTernaryFillerChar() {
+    var s0, s1, s2, s3;
+
+    s0 = peg$parse_EscapedChar();
+    if (s0 === peg$FAILED) {
+      s0 = peg$currPos;
+      s1 = peg$currPos;
+      peg$silentFails++;
+      if (input.substr(peg$currPos, 2) === peg$c6) {
+        s2 = peg$c6;
+        peg$currPos += 2;
+      } else {
+        s2 = peg$FAILED;
+        if (peg$silentFails === 0) { peg$fail(peg$e6); }
+      }
+      peg$silentFails--;
+      if (s2 === peg$FAILED) {
+        s1 = undefined;
+      } else {
+        peg$currPos = s1;
+        s1 = peg$FAILED;
+      }
+      if (s1 !== peg$FAILED) {
+        s2 = peg$currPos;
+        peg$silentFails++;
+        if (input.charCodeAt(peg$currPos) === 58) {
+          s3 = peg$c1;
+          peg$currPos++;
+        } else {
+          s3 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e1); }
+        }
+        peg$silentFails--;
+        if (s3 === peg$FAILED) {
+          s2 = undefined;
+        } else {
+          peg$currPos = s2;
+          s2 = peg$FAILED;
+        }
+        if (s2 !== peg$FAILED) {
+          if (input.length > peg$currPos) {
+            s3 = input.charAt(peg$currPos);
+            peg$currPos++;
+          } else {
+            s3 = peg$FAILED;
+            if (peg$silentFails === 0) { peg$fail(peg$e11); }
+          }
+          if (s3 !== peg$FAILED) {
+            s0 = s3;
+          } else {
+            peg$currPos = s0;
+            s0 = peg$FAILED;
+          }
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    }
+
+    return s0;
+  }
+
+  function peg$parseStringChar() {
+    var s0, s1, s2;
+
+    s0 = peg$parse_EscapedChar();
+    if (s0 === peg$FAILED) {
+      s0 = peg$currPos;
+      s1 = peg$currPos;
+      peg$silentFails++;
+      if (input.charCodeAt(peg$currPos) === 34) {
+        s2 = peg$c8;
+        peg$currPos++;
+      } else {
+        s2 = peg$FAILED;
+        if (peg$silentFails === 0) { peg$fail(peg$e8); }
+      }
+      peg$silentFails--;
+      if (s2 === peg$FAILED) {
+        s1 = undefined;
+      } else {
+        peg$currPos = s1;
+        s1 = peg$FAILED;
+      }
+      if (s1 !== peg$FAILED) {
+        if (input.length > peg$currPos) {
+          s2 = input.charAt(peg$currPos);
+          peg$currPos++;
+        } else {
+          s2 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e11); }
+        }
+        if (s2 !== peg$FAILED) {
+          s0 = s2;
+        } else {
+          peg$currPos = s0;
+          s0 = peg$FAILED;
+        }
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    }
+
+    return s0;
+  }
+
+  function peg$parse_EscapedChar() {
+    var s0, s1, s2;
+
+    s0 = peg$currPos;
+    if (input.charCodeAt(peg$currPos) === 92) {
+      s1 = peg$c11;
+      peg$currPos++;
+    } else {
+      s1 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e13); }
+    }
+    if (s1 !== peg$FAILED) {
+      if (input.length > peg$currPos) {
+        s2 = input.charAt(peg$currPos);
+        peg$currPos++;
+      } else {
+        s2 = peg$FAILED;
+        if (peg$silentFails === 0) { peg$fail(peg$e11); }
+      }
+      if (s2 !== peg$FAILED) {
+        s0 = s2;
+      } else {
+        peg$currPos = s0;
+        s0 = peg$FAILED;
+      }
+    } else {
+      peg$currPos = s0;
+      s0 = peg$FAILED;
+    }
+
+    return s0;
+  }
+
+  peg$result = peg$startRuleFunction();
+
+  if (options.peg$library) {
+    return /** @type {any} */ ({
+      peg$result,
+      peg$currPos,
+      peg$FAILED,
+      peg$maxFailExpected,
+      peg$maxFailPos
+    });
+  }
+  if (peg$result !== peg$FAILED && peg$currPos === input.length) {
+    return peg$result;
+  } else {
+    if (peg$result !== peg$FAILED && peg$currPos < input.length) {
+      peg$fail(peg$endExpectation());
+    }
+
+    throw peg$buildStructuredError(
+      peg$maxFailExpected,
+      peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null,
+      peg$maxFailPos < input.length
+        ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1)
+        : peg$computeLocation(peg$maxFailPos, peg$maxFailPos)
+    );
+  }
+}
+
+module.exports = {
+  StartRules: ["Format"],
+  SyntaxError: peg$SyntaxError,
+  parse: peg$parse
+};
diff --git a/frontend/shared/parsers/format.pegjs b/frontend/shared/parsers/format.pegjs
new file mode 100644
index 0000000000..40fe7cfa17
--- /dev/null
+++ b/frontend/shared/parsers/format.pegjs
@@ -0,0 +1,46 @@
+Format = Ternary / Statement
+
+// Ternary
+// ==========
+Ternary = c:Condition "?" t1:TernaryTrue ":" t2:TernaryFalse { return c ? t1 : t2; }
+TernaryTrue = Ternary / TernaryStatement
+TernaryFalse = Ternary / TernaryStatement
+
+// Statements
+// ==========
+Statement = sections:Section* { return sections.join(""); }
+TernaryStatement = sections:TernarySection* { return sections.join(""); }
+
+// Sections
+// ==========
+Section = Round / Placeholder / Filler
+TernarySection = Placeholder / TernaryFiller
+
+// Conditions
+// ==========
+Condition = Match / Exists
+Match = "match(" id:ConditionIdentifier "," val:(String / Integer) ")" { return options.getValue(id) == val; }
+Exists = "exists(" id:ConditionIdentifier ")" { return options.getValue(id) != null && options.getValue(id) != ""; }
+
+// Block of characters
+// ==========
+Placeholder = "${" identifier:Identifier "}" { return options.getValue(identifier); }
+Identifier = chars:IdentifierChar+ { return chars.join(""); }
+ConditionIdentifier = chars:ConditionIdentifierChar+ { return chars.join(""); }
+Filler = chars:FillerChar+ { return chars.join(""); }
+TernaryFiller = chars:TernaryFillerChar+ { return chars.join(""); }
+String = "\"" chars:StringChar* "\"" { return chars.join(""); }
+Integer = integer:[0-9]+ { return parseInt(integer, 10); }
+Round = "round(" id:ConditionIdentifier "," digits:(Integer) ")" {
+    var val = parseFloat(options.getValue(id));
+    return isFinite(val) ? val.toFixed(digits) : "";
+}
+
+// Characters
+// ==========
+IdentifierChar = _EscapedChar / !"}" @.
+ConditionIdentifierChar = _EscapedChar / !"," !")" @.
+FillerChar = _EscapedChar / !"${" !"round" @.
+TernaryFillerChar = _EscapedChar / !"${" !":" @.
+StringChar = _EscapedChar / !"\"" @.
+_EscapedChar = "\\" @.
diff --git a/frontend/shared/parsers/query.js b/frontend/shared/parsers/query.js
index 244e2317bf..15bf596792 100644
--- a/frontend/shared/parsers/query.js
+++ b/frontend/shared/parsers/query.js
@@ -1,62 +1,101 @@
-/*
- * Generated by PEG.js 0.10.0.
- *
- * http://pegjs.org/
- */
+// @generated by Peggy 4.0.2.
+//
+// https://peggyjs.org/
 
 "use strict";
 
+
 function peg$subclass(child, parent) {
-  function ctor() { this.constructor = child; }
-  ctor.prototype = parent.prototype;
-  child.prototype = new ctor();
+  function C() { this.constructor = child; }
+  C.prototype = parent.prototype;
+  child.prototype = new C();
 }
 
 function peg$SyntaxError(message, expected, found, location) {
-  this.message  = message;
-  this.expected = expected;
-  this.found    = found;
-  this.location = location;
-  this.name     = "SyntaxError";
-
-  if (typeof Error.captureStackTrace === "function") {
-    Error.captureStackTrace(this, peg$SyntaxError);
+  var self = Error.call(this, message);
+  // istanbul ignore next Check is a necessary evil to support older environments
+  if (Object.setPrototypeOf) {
+    Object.setPrototypeOf(self, peg$SyntaxError.prototype);
   }
+  self.expected = expected;
+  self.found = found;
+  self.location = location;
+  self.name = "SyntaxError";
+  return self;
 }
 
 peg$subclass(peg$SyntaxError, Error);
 
-peg$SyntaxError.buildMessage = function(expected, found) {
-  var DESCRIBE_EXPECTATION_FNS = {
-        literal: function(expectation) {
-          return "\"" + literalEscape(expectation.text) + "\"";
-        },
-
-        "class": function(expectation) {
-          var escapedParts = "",
-              i;
-
-          for (i = 0; i < expectation.parts.length; i++) {
-            escapedParts += expectation.parts[i] instanceof Array
-              ? classEscape(expectation.parts[i][0]) + "-" + classEscape(expectation.parts[i][1])
-              : classEscape(expectation.parts[i]);
-          }
-
-          return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]";
-        },
-
-        any: function(expectation) {
-          return "any character";
-        },
+function peg$padEnd(str, targetLength, padString) {
+  padString = padString || " ";
+  if (str.length > targetLength) { return str; }
+  targetLength -= str.length;
+  padString += padString.repeat(targetLength);
+  return str + padString.slice(0, targetLength);
+}
 
-        end: function(expectation) {
-          return "end of input";
-        },
+peg$SyntaxError.prototype.format = function(sources) {
+  var str = "Error: " + this.message;
+  if (this.location) {
+    var src = null;
+    var k;
+    for (k = 0; k < sources.length; k++) {
+      if (sources[k].source === this.location.source) {
+        src = sources[k].text.split(/\r\n|\n|\r/g);
+        break;
+      }
+    }
+    var s = this.location.start;
+    var offset_s = (this.location.source && (typeof this.location.source.offset === "function"))
+      ? this.location.source.offset(s)
+      : s;
+    var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column;
+    if (src) {
+      var e = this.location.end;
+      var filler = peg$padEnd("", offset_s.line.toString().length, ' ');
+      var line = src[s.line - 1];
+      var last = s.line === e.line ? e.column : line.length + 1;
+      var hatLen = (last - s.column) || 1;
+      str += "\n --> " + loc + "\n"
+          + filler + " |\n"
+          + offset_s.line + " | " + line + "\n"
+          + filler + " | " + peg$padEnd("", s.column - 1, ' ')
+          + peg$padEnd("", hatLen, "^");
+    } else {
+      str += "\n at " + loc;
+    }
+  }
+  return str;
+};
 
-        other: function(expectation) {
-          return expectation.description;
-        }
-      };
+peg$SyntaxError.buildMessage = function(expected, found) {
+  var DESCRIBE_EXPECTATION_FNS = {
+    literal: function(expectation) {
+      return "\"" + literalEscape(expectation.text) + "\"";
+    },
+
+    class: function(expectation) {
+      var escapedParts = expectation.parts.map(function(part) {
+        return Array.isArray(part)
+          ? classEscape(part[0]) + "-" + classEscape(part[1])
+          : classEscape(part);
+      });
+
+      return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]";
+    },
+
+    any: function() {
+      return "any character";
+    },
+
+    end: function() {
+      return "end of input";
+    },
+
+    other: function(expectation) {
+      return expectation.description;
+    }
+  };
 
   function hex(ch) {
     return ch.charCodeAt(0).toString(16).toUpperCase();
@@ -64,28 +103,28 @@ peg$SyntaxError.buildMessage = function(expected, found) {
 
   function literalEscape(s) {
     return s
-      .replace(/\\/g, '\\\\')
-      .replace(/"/g,  '\\"')
-      .replace(/\0/g, '\\0')
-      .replace(/\t/g, '\\t')
-      .replace(/\n/g, '\\n')
-      .replace(/\r/g, '\\r')
-      .replace(/[\x00-\x0F]/g,          function(ch) { return '\\x0' + hex(ch); })
-      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x'  + hex(ch); });
+      .replace(/\\/g, "\\\\")
+      .replace(/"/g,  "\\\"")
+      .replace(/\0/g, "\\0")
+      .replace(/\t/g, "\\t")
+      .replace(/\n/g, "\\n")
+      .replace(/\r/g, "\\r")
+      .replace(/[\x00-\x0F]/g,          function(ch) { return "\\x0" + hex(ch); })
+      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x"  + hex(ch); });
   }
 
   function classEscape(s) {
     return s
-      .replace(/\\/g, '\\\\')
-      .replace(/\]/g, '\\]')
-      .replace(/\^/g, '\\^')
-      .replace(/-/g,  '\\-')
-      .replace(/\0/g, '\\0')
-      .replace(/\t/g, '\\t')
-      .replace(/\n/g, '\\n')
-      .replace(/\r/g, '\\r')
-      .replace(/[\x00-\x0F]/g,          function(ch) { return '\\x0' + hex(ch); })
-      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x'  + hex(ch); });
+      .replace(/\\/g, "\\\\")
+      .replace(/\]/g, "\\]")
+      .replace(/\^/g, "\\^")
+      .replace(/-/g,  "\\-")
+      .replace(/\0/g, "\\0")
+      .replace(/\t/g, "\\t")
+      .replace(/\n/g, "\\n")
+      .replace(/\r/g, "\\r")
+      .replace(/[\x00-\x0F]/g,          function(ch) { return "\\x0" + hex(ch); })
+      .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x"  + hex(ch); });
   }
 
   function describeExpectation(expectation) {
@@ -93,12 +132,8 @@ peg$SyntaxError.buildMessage = function(expected, found) {
   }
 
   function describeExpected(expected) {
-    var descriptions = new Array(expected.length),
-        i, j;
-
-    for (i = 0; i < expected.length; i++) {
-      descriptions[i] = describeExpectation(expected[i]);
-    }
+    var descriptions = expected.map(describeExpectation);
+    var i, j;
 
     descriptions.sort();
 
@@ -134,43 +169,46 @@ peg$SyntaxError.buildMessage = function(expected, found) {
 };
 
 function peg$parse(input, options) {
-  options = options !== void 0 ? options : {};
-
-  var peg$FAILED = {},
-
-      peg$startRuleFunctions = { Query: peg$parseQuery },
-      peg$startRuleFunction  = peg$parseQuery,
-
-      peg$c0 = "or",
-      peg$c1 = peg$literalExpectation("OR", true),
-      peg$c2 = function(left, right) {return options.orValues(left, right);},
-      peg$c3 = "and",
-      peg$c4 = peg$literalExpectation("AND", true),
-      peg$c5 = function(left, right) {return options.andValues(left, right);},
-      peg$c6 = "not",
-      peg$c7 = peg$literalExpectation("NOT", true),
-      peg$c8 = function(value) {return options.negateValue(value);},
-      peg$c9 = "(",
-      peg$c10 = peg$literalExpectation("(", false),
-      peg$c11 = ")",
-      peg$c12 = peg$literalExpectation(")", false),
-      peg$c13 = function(query) {return query;},
-      peg$c14 = /^[0-9]/,
-      peg$c15 = peg$classExpectation([["0", "9"]], false, false),
-      peg$c16 = function(integer) {return options.getValue(parseInt(integer.join(""), 10));},
-      peg$c17 = /^[ \t\n\r]/,
-      peg$c18 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false),
-
-      peg$currPos          = 0,
-      peg$savedPos         = 0,
-      peg$posDetailsCache  = [{ line: 1, column: 1 }],
-      peg$maxFailPos       = 0,
-      peg$maxFailExpected  = [],
-      peg$silentFails      = 0,
-
-      peg$result;
-
-  if ("startRule" in options) {
+  options = options !== undefined ? options : {};
+
+  var peg$FAILED = {};
+  var peg$source = options.grammarSource;
+
+  var peg$startRuleFunctions = { Query: peg$parseQuery };
+  var peg$startRuleFunction = peg$parseQuery;
+
+  var peg$c0 = "or";
+  var peg$c1 = "and";
+  var peg$c2 = "not";
+  var peg$c3 = "(";
+  var peg$c4 = ")";
+
+  var peg$r0 = /^[0-9]/;
+  var peg$r1 = /^[ \t\n\r]/;
+
+  var peg$e0 = peg$literalExpectation("OR", true);
+  var peg$e1 = peg$literalExpectation("AND", true);
+  var peg$e2 = peg$literalExpectation("NOT", true);
+  var peg$e3 = peg$literalExpectation("(", false);
+  var peg$e4 = peg$literalExpectation(")", false);
+  var peg$e5 = peg$classExpectation([["0", "9"]], false, false);
+  var peg$e6 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false);
+
+  var peg$f0 = function(left, right) {return options.orValues(left, right);};
+  var peg$f1 = function(left, right) {return options.andValues(left, right);};
+  var peg$f2 = function(value) {return options.negateValue(value);};
+  var peg$f3 = function(query) {return query;};
+  var peg$f4 = function(integer) {return options.getValue(parseInt(integer.join(""), 10));};
+  var peg$currPos = options.peg$currPos | 0;
+  var peg$savedPos = peg$currPos;
+  var peg$posDetailsCache = [{ line: 1, column: 1 }];
+  var peg$maxFailPos = peg$currPos;
+  var peg$maxFailExpected = options.peg$maxFailExpected || [];
+  var peg$silentFails = options.peg$silentFails | 0;
+
+  var peg$result;
+
+  if (options.startRule) {
     if (!(options.startRule in peg$startRuleFunctions)) {
       throw new Error("Can't start parsing from rule \"" + options.startRule + "\".");
     }
@@ -182,12 +220,26 @@ function peg$parse(input, options) {
     return input.substring(peg$savedPos, peg$currPos);
   }
 
+  function offset() {
+    return peg$savedPos;
+  }
+
+  function range() {
+    return {
+      source: peg$source,
+      start: peg$savedPos,
+      end: peg$currPos
+    };
+  }
+
   function location() {
     return peg$computeLocation(peg$savedPos, peg$currPos);
   }
 
   function expected(description, location) {
-    location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos)
+    location = location !== undefined
+      ? location
+      : peg$computeLocation(peg$savedPos, peg$currPos);
 
     throw peg$buildStructuredError(
       [peg$otherExpectation(description)],
@@ -197,7 +249,9 @@ function peg$parse(input, options) {
   }
 
   function error(message, location) {
-    location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos)
+    location = location !== undefined
+      ? location
+      : peg$computeLocation(peg$savedPos, peg$currPos);
 
     throw peg$buildSimpleError(message, location);
   }
@@ -223,19 +277,22 @@ function peg$parse(input, options) {
   }
 
   function peg$computePosDetails(pos) {
-    var details = peg$posDetailsCache[pos], p;
+    var details = peg$posDetailsCache[pos];
+    var p;
 
     if (details) {
       return details;
     } else {
-      p = pos - 1;
-      while (!peg$posDetailsCache[p]) {
-        p--;
+      if (pos >= peg$posDetailsCache.length) {
+        p = peg$posDetailsCache.length - 1;
+      } else {
+        p = pos;
+        while (!peg$posDetailsCache[--p]) {}
       }
 
       details = peg$posDetailsCache[p];
       details = {
-        line:   details.line,
+        line: details.line,
         column: details.column
       };
 
@@ -251,26 +308,33 @@ function peg$parse(input, options) {
       }
 
       peg$posDetailsCache[pos] = details;
+
       return details;
     }
   }
 
-  function peg$computeLocation(startPos, endPos) {
-    var startPosDetails = peg$computePosDetails(startPos),
-        endPosDetails   = peg$computePosDetails(endPos);
+  function peg$computeLocation(startPos, endPos, offset) {
+    var startPosDetails = peg$computePosDetails(startPos);
+    var endPosDetails = peg$computePosDetails(endPos);
 
-    return {
+    var res = {
+      source: peg$source,
       start: {
         offset: startPos,
-        line:   startPosDetails.line,
+        line: startPosDetails.line,
         column: startPosDetails.column
       },
       end: {
         offset: endPos,
-        line:   endPosDetails.line,
+        line: endPosDetails.line,
         column: endPosDetails.column
       }
     };
+    if (offset && peg$source && (typeof peg$source.offset === "function")) {
+      res.start = peg$source.offset(res.start);
+      res.end = peg$source.offset(res.end);
+    }
+    return res;
   }
 
   function peg$fail(expected) {
@@ -311,19 +375,18 @@ function peg$parse(input, options) {
     s0 = peg$currPos;
     s1 = peg$parseAnd();
     if (s1 !== peg$FAILED) {
-      if (input.substr(peg$currPos, 2).toLowerCase() === peg$c0) {
-        s2 = input.substr(peg$currPos, 2);
+      s2 = input.substr(peg$currPos, 2);
+      if (s2.toLowerCase() === peg$c0) {
         peg$currPos += 2;
       } else {
         s2 = peg$FAILED;
-        if (peg$silentFails === 0) { peg$fail(peg$c1); }
+        if (peg$silentFails === 0) { peg$fail(peg$e0); }
       }
       if (s2 !== peg$FAILED) {
         s3 = peg$parseOr();
         if (s3 !== peg$FAILED) {
           peg$savedPos = s0;
-          s1 = peg$c2(s1, s3);
-          s0 = s1;
+          s0 = peg$f0(s1, s3);
         } else {
           peg$currPos = s0;
           s0 = peg$FAILED;
@@ -349,19 +412,18 @@ function peg$parse(input, options) {
     s0 = peg$currPos;
     s1 = peg$parseNot();
     if (s1 !== peg$FAILED) {
-      if (input.substr(peg$currPos, 3).toLowerCase() === peg$c3) {
-        s2 = input.substr(peg$currPos, 3);
+      s2 = input.substr(peg$currPos, 3);
+      if (s2.toLowerCase() === peg$c1) {
         peg$currPos += 3;
       } else {
         s2 = peg$FAILED;
-        if (peg$silentFails === 0) { peg$fail(peg$c4); }
+        if (peg$silentFails === 0) { peg$fail(peg$e1); }
       }
       if (s2 !== peg$FAILED) {
         s3 = peg$parseAnd();
         if (s3 !== peg$FAILED) {
           peg$savedPos = s0;
-          s1 = peg$c5(s1, s3);
-          s0 = s1;
+          s0 = peg$f1(s1, s3);
         } else {
           peg$currPos = s0;
           s0 = peg$FAILED;
@@ -386,30 +448,19 @@ function peg$parse(input, options) {
 
     s0 = peg$currPos;
     s1 = peg$parse_();
-    if (s1 !== peg$FAILED) {
-      if (input.substr(peg$currPos, 3).toLowerCase() === peg$c6) {
-        s2 = input.substr(peg$currPos, 3);
-        peg$currPos += 3;
-      } else {
-        s2 = peg$FAILED;
-        if (peg$silentFails === 0) { peg$fail(peg$c7); }
-      }
-      if (s2 !== peg$FAILED) {
-        s3 = peg$parse_();
-        if (s3 !== peg$FAILED) {
-          s4 = peg$parseNot();
-          if (s4 !== peg$FAILED) {
-            peg$savedPos = s0;
-            s1 = peg$c8(s4);
-            s0 = s1;
-          } else {
-            peg$currPos = s0;
-            s0 = peg$FAILED;
-          }
-        } else {
-          peg$currPos = s0;
-          s0 = peg$FAILED;
-        }
+    s2 = input.substr(peg$currPos, 3);
+    if (s2.toLowerCase() === peg$c2) {
+      peg$currPos += 3;
+    } else {
+      s2 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e2); }
+    }
+    if (s2 !== peg$FAILED) {
+      s3 = peg$parse_();
+      s4 = peg$parseNot();
+      if (s4 !== peg$FAILED) {
+        peg$savedPos = s0;
+        s0 = peg$f2(s4);
       } else {
         peg$currPos = s0;
         s0 = peg$FAILED;
@@ -430,50 +481,29 @@ function peg$parse(input, options) {
 
     s0 = peg$currPos;
     s1 = peg$parse_();
-    if (s1 !== peg$FAILED) {
-      if (input.charCodeAt(peg$currPos) === 40) {
-        s2 = peg$c9;
-        peg$currPos++;
-      } else {
-        s2 = peg$FAILED;
-        if (peg$silentFails === 0) { peg$fail(peg$c10); }
-      }
-      if (s2 !== peg$FAILED) {
-        s3 = peg$parse_();
-        if (s3 !== peg$FAILED) {
-          s4 = peg$parseOr();
-          if (s4 !== peg$FAILED) {
-            s5 = peg$parse_();
-            if (s5 !== peg$FAILED) {
-              if (input.charCodeAt(peg$currPos) === 41) {
-                s6 = peg$c11;
-                peg$currPos++;
-              } else {
-                s6 = peg$FAILED;
-                if (peg$silentFails === 0) { peg$fail(peg$c12); }
-              }
-              if (s6 !== peg$FAILED) {
-                s7 = peg$parse_();
-                if (s7 !== peg$FAILED) {
-                  peg$savedPos = s0;
-                  s1 = peg$c13(s4);
-                  s0 = s1;
-                } else {
-                  peg$currPos = s0;
-                  s0 = peg$FAILED;
-                }
-              } else {
-                peg$currPos = s0;
-                s0 = peg$FAILED;
-              }
-            } else {
-              peg$currPos = s0;
-              s0 = peg$FAILED;
-            }
-          } else {
-            peg$currPos = s0;
-            s0 = peg$FAILED;
-          }
+    if (input.charCodeAt(peg$currPos) === 40) {
+      s2 = peg$c3;
+      peg$currPos++;
+    } else {
+      s2 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e3); }
+    }
+    if (s2 !== peg$FAILED) {
+      s3 = peg$parse_();
+      s4 = peg$parseOr();
+      if (s4 !== peg$FAILED) {
+        s5 = peg$parse_();
+        if (input.charCodeAt(peg$currPos) === 41) {
+          s6 = peg$c4;
+          peg$currPos++;
+        } else {
+          s6 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e4); }
+        }
+        if (s6 !== peg$FAILED) {
+          s7 = peg$parse_();
+          peg$savedPos = s0;
+          s0 = peg$f3(s4);
         } else {
           peg$currPos = s0;
           s0 = peg$FAILED;
@@ -498,43 +528,32 @@ function peg$parse(input, options) {
 
     s0 = peg$currPos;
     s1 = peg$parse_();
-    if (s1 !== peg$FAILED) {
-      s2 = [];
-      if (peg$c14.test(input.charAt(peg$currPos))) {
+    s2 = [];
+    s3 = input.charAt(peg$currPos);
+    if (peg$r0.test(s3)) {
+      peg$currPos++;
+    } else {
+      s3 = peg$FAILED;
+      if (peg$silentFails === 0) { peg$fail(peg$e5); }
+    }
+    if (s3 !== peg$FAILED) {
+      while (s3 !== peg$FAILED) {
+        s2.push(s3);
         s3 = input.charAt(peg$currPos);
-        peg$currPos++;
-      } else {
-        s3 = peg$FAILED;
-        if (peg$silentFails === 0) { peg$fail(peg$c15); }
-      }
-      if (s3 !== peg$FAILED) {
-        while (s3 !== peg$FAILED) {
-          s2.push(s3);
-          if (peg$c14.test(input.charAt(peg$currPos))) {
-            s3 = input.charAt(peg$currPos);
-            peg$currPos++;
-          } else {
-            s3 = peg$FAILED;
-            if (peg$silentFails === 0) { peg$fail(peg$c15); }
-          }
-        }
-      } else {
-        s2 = peg$FAILED;
-      }
-      if (s2 !== peg$FAILED) {
-        s3 = peg$parse_();
-        if (s3 !== peg$FAILED) {
-          peg$savedPos = s0;
-          s1 = peg$c16(s2);
-          s0 = s1;
+        if (peg$r0.test(s3)) {
+          peg$currPos++;
         } else {
-          peg$currPos = s0;
-          s0 = peg$FAILED;
+          s3 = peg$FAILED;
+          if (peg$silentFails === 0) { peg$fail(peg$e5); }
         }
-      } else {
-        peg$currPos = s0;
-        s0 = peg$FAILED;
       }
+    } else {
+      s2 = peg$FAILED;
+    }
+    if (s2 !== peg$FAILED) {
+      s3 = peg$parse_();
+      peg$savedPos = s0;
+      s0 = peg$f4(s2);
     } else {
       peg$currPos = s0;
       s0 = peg$FAILED;
@@ -547,21 +566,21 @@ function peg$parse(input, options) {
     var s0, s1;
 
     s0 = [];
-    if (peg$c17.test(input.charAt(peg$currPos))) {
-      s1 = input.charAt(peg$currPos);
+    s1 = input.charAt(peg$currPos);
+    if (peg$r1.test(s1)) {
       peg$currPos++;
     } else {
       s1 = peg$FAILED;
-      if (peg$silentFails === 0) { peg$fail(peg$c18); }
+      if (peg$silentFails === 0) { peg$fail(peg$e6); }
     }
     while (s1 !== peg$FAILED) {
       s0.push(s1);
-      if (peg$c17.test(input.charAt(peg$currPos))) {
-        s1 = input.charAt(peg$currPos);
+      s1 = input.charAt(peg$currPos);
+      if (peg$r1.test(s1)) {
         peg$currPos++;
       } else {
         s1 = peg$FAILED;
-        if (peg$silentFails === 0) { peg$fail(peg$c18); }
+        if (peg$silentFails === 0) { peg$fail(peg$e6); }
       }
     }
 
@@ -570,6 +589,15 @@ function peg$parse(input, options) {
 
   peg$result = peg$startRuleFunction();
 
+  if (options.peg$library) {
+    return /** @type {any} */ ({
+      peg$result,
+      peg$currPos,
+      peg$FAILED,
+      peg$maxFailExpected,
+      peg$maxFailPos
+    });
+  }
   if (peg$result !== peg$FAILED && peg$currPos === input.length) {
     return peg$result;
   } else {
@@ -588,6 +616,7 @@ function peg$parse(input, options) {
 }
 
 module.exports = {
+  StartRules: ["Query"],
   SyntaxError: peg$SyntaxError,
-  parse:       peg$parse
+  parse: peg$parse
 };
diff --git a/frontend/summary/dataPivot/ConditionalFormat.js b/frontend/summary/dataPivot/ConditionalFormat.js
index 7cd81524b4..e29ed32e2c 100644
--- a/frontend/summary/dataPivot/ConditionalFormat.js
+++ b/frontend/summary/dataPivot/ConditionalFormat.js
@@ -192,15 +192,11 @@ class _DataPivot_settings_conditional {
                 var lbl = $("<label>").html(desc_txt);
                 parent.append(lbl, inp);
             },
-            fieldName = $('<select class="form-control" name="field_name">')
-                .html(dp._get_header_options(true))
+            fieldName = dp.column_select_manager
+                .createSelect(true, {name: "field_name"})
                 .val(values.field_name || defaults.field_name),
             conditionType = $('<select class="form-control" name="condition_type">')
-                .html(
-                    formattingTypes.map(function(v) {
-                        return `<option value="${v}">${v}</option>`;
-                    })
-                )
+                .html(formattingTypes.map(v => `<option value="${v}">${v}</option>`))
                 .val(values.condition_type || defaults.condition_type),
             changeConditionType = function() {
                 div.find(".conditionalDivs").hide();
diff --git a/frontend/summary/dataPivot/DPFDescriptionTab.js b/frontend/summary/dataPivot/DPFDescriptionTab.js
index b6e10993e1..80749f6052 100644
--- a/frontend/summary/dataPivot/DPFDescriptionTab.js
+++ b/frontend/summary/dataPivot/DPFDescriptionTab.js
@@ -1,6 +1,11 @@
 import $ from "$";
 
-import {_DataPivot_settings_description, buildColGroup, buildHeaderTr} from "./DataPivotUtilities";
+import {
+    _DataPivot_settings_calculated,
+    _DataPivot_settings_description,
+    buildColGroup,
+    buildHeaderTr,
+} from "./DataPivotUtilities";
 
 let buildDescriptionTable = function(tab, dp) {
         let thead = $("<thead>").html(
@@ -41,9 +46,49 @@ let buildDescriptionTable = function(tab, dp) {
 
         return tab.append([newRowBtn, $("<h3>Descriptive text columns</h3>")], tbl);
     },
+    buildCalculatedTable = function(tab, dp) {
+        let thead = $("<thead>").html(buildHeaderTr(["Column name", "Column formula", "Delete"])),
+            colgroup = buildColGroup(["200px", "", "120px"]),
+            tbody = $("<tbody>"),
+            tbl = $('<table class="table table-sm table-bordered">').html([thead, colgroup, tbody]),
+            settings = dp.settings.calculated_columns,
+            addDataRow = i => {
+                let obj;
+                if (!settings[i]) {
+                    settings.push(_DataPivot_settings_calculated.defaults());
+                }
+                obj = new _DataPivot_settings_calculated(dp, settings[i]);
+                tbody.append(obj.tr);
+            },
+            newRowBtn = $(
+                '<button class="btn btn-primary float-right"><i class="fa fa-fw fa-plus"></i>&nbsp;Add row</button>'
+            ).on("click", () => addDataRow(settings.length)),
+            numRows = settings.length === 0 ? 2 : settings.length;
+
+        for (var i = 0; i < numRows; i++) {
+            addDataRow(i);
+        }
+
+        return tab.append(
+            [newRowBtn, $("<h3>Calculated columns</h3>")],
+            tbl,
+            `<div class="text-muted-text">
+                <p>Build new columns from existing columns. The mini-language is described below:</p>
+                <ul>
+                    <li><code>\${COLNAME}</code>: print a column value</li>
+                    <li><code>\${round(COLNAME,2)}</code>: rounds a column to 2 decimal points</li>
+                    <li><code>exists(COLNAME)?hello:world</code>: if COLNAME is not empty, print hello, else world</li>
+                    <li><code>match(COLNAME,"hello")?hello:goodbye</code>: if COLNAME equals "hello", print hello, else goodbye</li>
+
+                </ul>
+                <p>You can compose the language to build complex strings, for example: <code>\${response} ± round(stdev,2) (round(percent control mean,1)%)</code>. We are in the process of writing better documentation with examples. Please contact us if you'd like sooner!</p>
+            </div>`
+        );
+    },
     buildDescriptionTab = function(dp) {
         let tab = $('<div class="tab-pane active" id="data_pivot_settings_description">');
         buildDescriptionTable(tab, dp);
+        buildCalculatedTable(tab, dp);
         return tab;
     };
 
diff --git a/frontend/summary/dataPivot/DataPivot.js b/frontend/summary/dataPivot/DataPivot.js
index 34408c22ea..4e4ddcb5ca 100644
--- a/frontend/summary/dataPivot/DataPivot.js
+++ b/frontend/summary/dataPivot/DataPivot.js
@@ -5,11 +5,13 @@ import ReactDOM from "react-dom";
 import DataTable from "shared/components/DataTable";
 import HAWCModal from "shared/utils/HAWCModal";
 import HAWCUtils from "shared/utils/HAWCUtils";
+import h from "shared/utils/helpers";
 
 import $ from "$";
 
 import {getInteractivityOptions} from "../interactivity/actions";
 import {handleVisualError} from "../summary/common";
+import ColumnSelectManager from "./components/ColumnNameSelect";
 import DataPivotDefaultSettings from "./DataPivotDefaultSettings";
 import DataPivotVisualization from "./DataPivotVisualization";
 import build_data_tab from "./DPFDataTab";
@@ -18,7 +20,6 @@ import build_settings_general_tab from "./DPFGeneralSettingsTab";
 import build_ordering_tab from "./DPFOrderTab";
 import build_reference_tab from "./DPFReferenceTab";
 import build_styles_tab from "./DPFStyleTab";
-import {NULL_CASE} from "./shared";
 import Store from "./store";
 import StyleManager from "./StyleManager";
 
@@ -27,30 +28,39 @@ class DataPivot {
         if (_.keys(settings).length == 0) {
             settings = DataPivot.default_plot_settings();
         }
-        this.data = data;
         this.settings = settings;
+        this.raw_data = data;
         this.title = title;
         this.url = url;
         this.onRendered = [];
+        this.processDataset();
         if (dom_bindings.update) {
             this.build_edit_settings(dom_bindings);
         }
     }
 
+    processDataset() {
+        this.data = DataPivotVisualization.processDataset(this.raw_data, this.settings);
+    }
+
     static get_object(pk, callback) {
-        $.get(`/summary/api/data_pivot/${pk}/`, function(d) {
-            d3.tsv(d.data_url, (row, idx) => DataPivot.massage_row(row, idx)).then(
-                data => {
-                    var dp = new DataPivot(data, d.settings, {}, d.title, d.url);
-                    if (callback) {
-                        callback(dp);
-                    } else {
-                        return dp;
-                    }
-                },
-                error => handleVisualError(error, error)
-            );
-        });
+        const url = `/summary/api/data_pivot/${pk}/`;
+
+        fetch(url, h.fetchGet)
+            .then(d => d.json())
+            .then(d => {
+                fetch(d.data_url, h.fetchGet)
+                    .then(d => d.text())
+                    .then(data => d3.tsvParse(data))
+                    .then(data => {
+                        const dp = new DataPivot(data, d.settings, {}, d.title, d.url);
+                        if (callback) {
+                            callback(dp);
+                        }
+                    })
+                    .catch(err => handleVisualError(err, null));
+            })
+            .catch(err => handleVisualError(err, null));
     }
 
     static displayAsModal(id) {
@@ -69,8 +79,10 @@ class DataPivot {
     }
 
     static displayEditView(data_url, settings, options) {
-        d3.tsv(data_url, (d, i) => DataPivot.massage_row(d, i)).then(
-            data => {
+        fetch(data_url, h.fetchGet)
+            .then(d => d.text())
+            .then(data => d3.tsvParse(data))
+            .then(data => {
                 $("#loading_div").fadeOut();
                 const dp = new DataPivot(data, settings, {
                     update: true,
@@ -84,32 +96,14 @@ class DataPivot {
                     $(options.settingsField).val(dp.get_settings_json());
                     return true;
                 });
-            },
-            error => handleVisualError(error, null)
-        );
+            })
+            .catch(err => handleVisualError(err, null));
     }
 
     static default_plot_settings() {
         return DataPivotDefaultSettings;
     }
 
-    static massage_row(row, i) {
-        // make numbers in data numeric if possible
-        // see https://github.com/mbostock/d3/wiki/CSV
-        for (var field in row) {
-            // eslint-disable-next-line no-prototype-builtins
-            if (row.hasOwnProperty(field)) {
-                row[field] = +row[field] || row[field];
-            }
-        }
-
-        // add data-pivot row-level key and index
-        row._dp_y = i;
-        row._dp_pk = row["key"] || i;
-
-        return row;
-    }
-
     static move_row(arr, obj, moveUp) {
         // class-level function; used to delete a settings input row
         var swap = function(arr, a, b) {
@@ -188,7 +182,9 @@ class DataPivot {
     build_edit_settings(dom_bindings) {
         var self = this,
             editable = true;
+        this.store = new Store(this);
         this.style_manager = new StyleManager(this);
+        this.column_select_manager = new ColumnSelectManager(this);
         this.$div = $(dom_bindings.container);
         this.$data_div = $(dom_bindings.data_div);
         this.$settings_div = $(dom_bindings.settings_div);
@@ -201,7 +197,6 @@ class DataPivot {
             }
         });
 
-        this.store = new Store(this);
         this.build_data_table();
         this.build_settings();
         this.$div.fadeIn();
@@ -211,28 +206,19 @@ class DataPivot {
         try {
             delete this.plot;
             editable = editable || false;
-            var data = JSON.parse(JSON.stringify(this.data)); // deep-copy
-            this.plot = new DataPivotVisualization(data, this.settings, $div, editable);
+            this.plot = new DataPivotVisualization(
+                _.cloneDeep(this.raw_data),
+                this.settings,
+                $div,
+                editable
+            );
         } catch (err) {
             handleVisualError(err, $div);
         }
     }
 
     build_data_table() {
-        // get headers
-        var data_headers = [];
-        for (var prop in this.data[0]) {
-            // eslint-disable-next-line no-prototype-builtins
-            if (this.data[0].hasOwnProperty(prop)) {
-                data_headers.push(prop);
-            }
-        }
-
-        // build data table
         ReactDOM.render(<DataTable dataset={this.data} />, this.$data_div[0]);
-
-        // now save things back to object
-        this.data_headers = data_headers;
     }
 
     addOnRenderedCallback(cb) {
@@ -277,22 +263,6 @@ class DataPivot {
         });
     }
 
-    _get_header_options(show_blank) {
-        var opts = [];
-        if (show_blank) opts.push(`<option value="${NULL_CASE}">${NULL_CASE}</option>`);
-        return opts.concat(this.data_headers.map(v => `<option value="${v}">${v}</option>`));
-    }
-
-    _get_header_options_react(show_blank = false) {
-        const options = this.data_headers.map(d => {
-            return {id: d, label: d};
-        });
-        if (show_blank) {
-            options.unshift({id: NULL_CASE, label: NULL_CASE});
-        }
-        return options;
-    }
-
     _get_description_options() {
         return this.settings.description_settings.map(
             (d, i) => `<option value="${i}">${d.header_name}</option>`
diff --git a/frontend/summary/dataPivot/DataPivotDefaultSettings.js b/frontend/summary/dataPivot/DataPivotDefaultSettings.js
index 1edff253ab..d3ed60731d 100644
--- a/frontend/summary/dataPivot/DataPivotDefaultSettings.js
+++ b/frontend/summary/dataPivot/DataPivotDefaultSettings.js
@@ -43,6 +43,7 @@ export default {
     legend: DataPivotLegend.default_settings(),
     dataline_settings: [],
     datapoint_settings: [],
+    calculated_columns: [],
     description_settings: [],
     barchart: _DataPivot_settings_barchart.defaults(),
     spacers: [],
diff --git a/frontend/summary/dataPivot/DataPivotUtilities.js b/frontend/summary/dataPivot/DataPivotUtilities.js
index 312028a53e..187f751554 100644
--- a/frontend/summary/dataPivot/DataPivotUtilities.js
+++ b/frontend/summary/dataPivot/DataPivotUtilities.js
@@ -1,4 +1,5 @@
 import {renderClientSideAutosuggest} from "shared/components/Autocomplete";
+import Format from "shared/parsers/format";
 
 import $ from "$";
 
@@ -152,8 +153,7 @@ class _DataPivot_settings_label {
 
 class _DataPivot_settings_filters {
     constructor(data_pivot, values) {
-        var self = this,
-            valueTd = $("<td>").data("initialValue", values.value);
+        var valueTd = $("<td>").data("initialValue", values.value);
         this.data_pivot = data_pivot;
         this.values = values;
 
@@ -171,19 +171,15 @@ class _DataPivot_settings_filters {
 
         // create fields
         this.content = {};
-        this.content.field_name = $('<select class="form-control"></select>').html(
-            this.data_pivot._get_header_options(true)
-        );
-        this.content.quantifier = $('<select class="form-control"></select>').html(
-            get_quantifier_options()
-        );
+        this.content.field_name = data_pivot.column_select_manager
+            .createSelect(true)
+            .val(values.field_name);
+        this.content.quantifier = $('<select class="form-control">')
+            .html(get_quantifier_options())
+            .val(values.quantifier);
         this.content.valueTd = valueTd;
 
-        // set default values
-        this.content.field_name.find(`option[value="${values.field_name}"]`).prop("selected", true);
-        this.content.quantifier.find(`option[value="${values.quantifier}"]`).prop("selected", true);
-
-        var movement_td = DataPivot.build_movement_td(self.data_pivot.settings.filters, this, {
+        var movement_td = DataPivot.build_movement_td(data_pivot.settings.filters, this, {
             showSort: true,
         });
 
@@ -285,8 +281,8 @@ class _DataPivot_settings_description {
 
         // create fields
         this.content = {
-            field_name: $('<select class="form-control"></select>')
-                .html(this.data_pivot._get_header_options(true))
+            field_name: this.data_pivot.column_select_manager
+                .createSelect(true)
                 .val(values.field_name),
             header_name: $('<input class="form-control" type="text">').val(values.header_name),
             header_style: this.data_pivot.style_manager.add_select("texts", values.header_style),
@@ -315,9 +311,7 @@ class _DataPivot_settings_description {
             .append($("<td>").append(this.content.max_width))
             .append($("<td>").append(this.content.to_right))
             .append($("<td>").append(this.content.dpe))
-            .on("change", "input,select", function() {
-                self.data_push();
-            });
+            .on("change", "input,select", () => self.data_push());
 
         var movement_td = DataPivot.build_movement_td(
             self.data_pivot.settings.description_settings,
@@ -344,10 +338,9 @@ class _DataPivot_settings_description {
 
     data_push() {
         this.values.field_name = this.content.field_name.find("option:selected").val();
-        this.values.field_index = this.content.field_name.find("option:selected").val();
+        this.values.header_name = this.content.header_name.val();
         this.values.header_style = this.content.header_style.find("option:selected").val();
         this.values.text_style = this.content.text_style.find("option:selected").val();
-        this.values.header_name = this.content.header_name.val();
         this.values.max_width = parseFloat(this.content.max_width.val(), 10) || undefined;
         this.values.to_right = this.content.to_right.prop("checked");
         this.values.dpe = NULL_CASE;
@@ -360,6 +353,58 @@ class _DataPivot_settings_description {
     }
 }
 
+class _DataPivot_settings_calculated {
+    constructor(data_pivot, values) {
+        var self = this;
+        this.data_pivot = data_pivot;
+        this.values = values;
+
+        const checkFormula = function() {
+            const el = $(this);
+            try {
+                Format.parse(el.val(), {getValue: () => null});
+                el.removeClass("is-invalid");
+            } catch (err) {
+                el.addClass("is-invalid");
+            }
+        };
+
+        // create fields
+        this.content = {
+            name: $('<input class="form-control" type="text">').val(values.name),
+            formula: $('<input class="form-control" type="text">').val(values.formula),
+        };
+
+        this.tr = $("<tr>")
+            .append($("<td>").append(this.content.name))
+            .append($("<td>").append(this.content.formula))
+            .append(
+                DataPivot.build_movement_td(self.data_pivot.settings.calculated_columns, this, {
+                    showSort: false,
+                })
+            )
+            .on("change", "input,select", () => this.data_push())
+            .trigger("change");
+
+        this.content.formula.on("keyup", checkFormula).trigger("keyup");
+
+        return this;
+    }
+
+    static defaults() {
+        return {
+            name: "",
+            formula: "",
+        };
+    }
+
+    data_push() {
+        this.values.name = this.content.name.val();
+        this.values.formula = this.content.formula.val();
+        this.data_pivot.column_select_manager.updateSelects();
+    }
+}
+
 class _DataPivot_settings_pointdata {
     constructor(data_pivot, values) {
         var self = this,
@@ -375,8 +420,8 @@ class _DataPivot_settings_pointdata {
 
         // create fields
         this.content = {
-            field_name: $('<select class="form-control">')
-                .html(this.data_pivot._get_header_options(true))
+            field_name: this.data_pivot.column_select_manager
+                .createSelect(true)
                 .val(values.field_name),
             header_name: $('<input class="form-control" type="text">').val(values.header_name),
             marker_style: this.data_pivot.style_manager.add_select(style_type, values.marker_style),
@@ -468,28 +513,17 @@ class _DataPivot_settings_linedata {
 
         // create fields
         this.content = {
-            low_field_name: $('<select class="form-control"></select>').html(
-                this.data_pivot._get_header_options(true)
-            ),
-            high_field_name: $('<select class="form-control"></select>').html(
-                this.data_pivot._get_header_options(true)
-            ),
-            header_name: $('<input  class="form-control" type="text">'),
+            low_field_name: this.data_pivot.column_select_manager
+                .createSelect(true)
+                .val(values.low_field_name),
+            high_field_name: this.data_pivot.column_select_manager
+                .createSelect(true)
+                .val(values.high_field_name),
+            header_name: $('<input  class="form-control" type="text">').val(values.header_name),
             marker_style: this.data_pivot.style_manager.add_select(style_type, values.marker_style),
             conditional_formatting: this.conditional_formatter.data,
         };
 
-        // set default values
-        this.content.low_field_name
-            .find(`option[value="${values.low_field_name}"]`)
-            .prop("selected", true);
-
-        this.content.high_field_name
-            .find(`option[value="${values.high_field_name}"]`)
-            .prop("selected", true);
-
-        this.content.header_name.val(values.header_name);
-
         var header_input = this.content.header_name;
         this.content.low_field_name.on("change", function() {
             header_input.val(
@@ -792,20 +826,22 @@ class _DataPivot_settings_barchart {
             }),
             styleSelectFactory = dp.style_manager.add_select.bind(dp.style_manager),
             content = {
-                field_name: $('<select id="bc_field_name" name="field_name" class="form-control">')
-                    .html(dp._get_header_options(true))
+                field_name: dp.column_select_manager
+                    .createSelect(true, {id: "bc_field_name", name: "field_name"})
                     .val(values.field_name),
-                error_low_field_name: $(
-                    '<select id="bc_error_low_field_name" name="error_low_field_name" class="form-control">'
-                )
-                    .html(dp._get_header_options(true))
+
+                error_low_field_name: dp.column_select_manager
+                    .createSelect(true, {
+                        id: "bc_error_low_field_name",
+                        name: "error_low_field_name",
+                    })
                     .val(values.error_low_field_name),
-                error_high_field_name: $(
-                    '<select id="bc_error_high_field_name" name="error_high_field_name" class="form-control">'
-                )
-                    .html(dp._get_header_options(true))
+                error_high_field_name: dp.column_select_manager
+                    .createSelect(true, {
+                        id: "bc_error_high_field_name",
+                        name: "error_high_field_name",
+                    })
                     .val(values.error_high_field_name),
-
                 header_name: $(
                     '<input id="bc_header_name" name="header_name" type="text" class="form-control"/>'
                 ).val(values.header_name),
@@ -973,6 +1009,7 @@ export {_DataPivot_settings_label};
 export {_DataPivot_settings_filters};
 export {_DataPivot_settings_spacers};
 export {_DataPivot_settings_description};
+export {_DataPivot_settings_calculated};
 export {_DataPivot_settings_pointdata};
 export {_DataPivot_settings_linedata};
 export {_DataPivot_settings_barchart};
diff --git a/frontend/summary/dataPivot/DataPivotVisualization.js b/frontend/summary/dataPivot/DataPivotVisualization.js
index c6d45a3bf3..d2428bcbaf 100644
--- a/frontend/summary/dataPivot/DataPivotVisualization.js
+++ b/frontend/summary/dataPivot/DataPivotVisualization.js
@@ -1,5 +1,6 @@
 import * as d3 from "d3";
 import _ from "lodash";
+import Format from "shared/parsers/format";
 import Query from "shared/parsers/query";
 import D3Plot from "shared/utils/D3Plot";
 import HAWCUtils from "shared/utils/HAWCUtils";
@@ -21,7 +22,7 @@ class DataPivotVisualization extends D3Plot {
         // Metadata viewer visualization
         super();
         this.editable = editable || false;
-        this.dp_data = dp_data;
+        this.dp_data = DataPivotVisualization.processDataset(dp_data, dp_settings);
         this.dp_settings = dp_settings;
         this.plot_div = plot_div;
         this.set_defaults();
@@ -196,6 +197,35 @@ class DataPivotVisualization extends D3Plot {
         return $.isNumeric(row[bar.low_field_name]) && $.isNumeric(row[bar.high_field_name]);
     }
 
+    static processDataset(dataset, settings) {
+        const parseRow = (row, i) => {
+            // make numbers in data numeric if possible
+            // see https://github.com/mbostock/d3/wiki/CSV
+            _.each(row, (_, key) => {
+                row[key] = +row[key] || row[key];
+            });
+
+            // add data-pivot row-level key and index
+            row._dp_y = i;
+            row._dp_pk = row["key"] || i;
+
+            settings.calculated_columns
+                .filter(d => d.name && d.formula)
+                .forEach(d => {
+                    try {
+                        row[d.name] = Format.parse(d.formula, {
+                            getValue: identifier => row[identifier],
+                        });
+                    } catch (err) {
+                        console.warn(d.formula, row);
+                        row[d.name] = "";
+                    }
+                });
+            return row;
+        };
+        return _.cloneDeep(dataset).map(d => parseRow(d));
+    }
+
     set_defaults() {
         const gridline_stroke = this.dp_settings.plot_settings.show_xticks
             ? this.dp_settings.plot_settings.gridline_color
diff --git a/frontend/summary/dataPivot/components/ColumnNameSelect.js b/frontend/summary/dataPivot/components/ColumnNameSelect.js
new file mode 100644
index 0000000000..20112c66b2
--- /dev/null
+++ b/frontend/summary/dataPivot/components/ColumnNameSelect.js
@@ -0,0 +1,55 @@
+import _ from "lodash";
+
+import {NULL_CASE} from "../shared";
+
+class ColumnSelect {
+    constructor(mgr, showBlank, attrs) {
+        this.mgr = mgr;
+        this.showBlank = showBlank;
+        this.select = $('<select class="form-control">');
+        if (attrs) {
+            _.forEach(attrs, (v, k) => this.select.attr(k, v));
+        }
+        this.update();
+    }
+    update() {
+        const selectedValue = this.select.find("option:selected").val(),
+            opts = this.mgr.headers.map(v => `<option value="${v}">${v}</option>`);
+        if (this.showBlank) {
+            opts.unshift(`<option value="${NULL_CASE}">${NULL_CASE}</option>`);
+        }
+        this.select.html(opts.concat());
+        if (selectedValue) {
+            this.select.find(`option[value="${selectedValue}"]`).prop("selected", true);
+        }
+    }
+}
+class ColumnSelectManager {
+    constructor(data_pivot) {
+        this.data_pivot = data_pivot;
+        this.selects = [];
+        this.updateHeaders();
+    }
+    createSelect(showBlank, attrs) {
+        const select = new ColumnSelect(this, showBlank, attrs);
+        this.selects.push(select);
+        return select.select;
+    }
+    updateSelects() {
+        this.updateHeaders();
+        this.selects.forEach(select => select.update());
+    }
+    updateHeaders() {
+        const firstRow = this.data_pivot.raw_data[0],
+            headers = _.keys(firstRow);
+        headers.push(
+            ...this.data_pivot.settings.calculated_columns
+                .filter(d => d.name.length > 0)
+                .map(d => d.name)
+        );
+        this.headers = headers;
+        this.data_pivot.store.sortStore.updateFieldColumns(headers);
+    }
+}
+
+export default ColumnSelectManager;
diff --git a/frontend/summary/dataPivot/components/SortTable.js b/frontend/summary/dataPivot/components/SortTable.js
index 998160d348..d368282f30 100644
--- a/frontend/summary/dataPivot/components/SortTable.js
+++ b/frontend/summary/dataPivot/components/SortTable.js
@@ -45,7 +45,7 @@ class SortingTable extends Component {
                                             handleSelect={value => {
                                                 store.updateElement(idx, "field_name", value);
                                             }}
-                                            choices={dp._get_header_options_react(true)}
+                                            choices={store.fieldColumns}
                                             required={false}
                                             value={data.field_name}
                                         />
diff --git a/frontend/summary/dataPivot/store.js b/frontend/summary/dataPivot/store.js
index 5e408a3c5a..eed13e59f8 100644
--- a/frontend/summary/dataPivot/store.js
+++ b/frontend/summary/dataPivot/store.js
@@ -15,6 +15,15 @@ class SortStore {
     }
 
     @observable settings = null;
+    @observable fieldColumns = [];
+
+    @action.bound updateFieldColumns(fieldColumns) {
+        const options = fieldColumns.map(d => {
+            return {id: d, label: d};
+        });
+        options.unshift({id: NULL_CASE, label: NULL_CASE});
+        this.fieldColumns = options;
+    }
 
     @action.bound createNew() {
         this.settings.push({
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 3cc9d02d3f..c24018f6da 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1217,6 +1217,13 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
+"@peggyjs/from-mem@1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@peggyjs/from-mem/-/from-mem-1.2.1.tgz#b83db5b439f1b02159fe47f5c9684bd1ee244edd"
+  integrity sha512-qh5zG8WKT36142/FqOYtpF0scRR3ZJ3H5XST1bJ/KV2FvyB5MvUB/tB9ZjihRe1iKjJD4PBOZczzwEx7hJtgMw==
+  dependencies:
+    semver "7.6.0"
+
 "@pkgjs/parseargs@^0.11.0":
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
@@ -2619,6 +2626,11 @@ commander@^10.0.1:
   resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
   integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==
 
+commander@^12.0.0:
+  version "12.0.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-12.0.0.tgz#b929db6df8546080adfd004ab215ed48cf6f2592"
+  integrity sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==
+
 commander@^4.0.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
@@ -6853,6 +6865,15 @@ pbf@^3.2.1:
     ieee754 "^1.1.12"
     resolve-protobuf-schema "^2.1.0"
 
+peggy@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/peggy/-/peggy-4.0.2.tgz#440b79ff8270668294d877addb3a30bb9e28bad5"
+  integrity sha512-j4cepPgB20V7honmTAI+7U022y/n/wVi7Rbbd2QrMl2nifFECpngvA6Zhrz/JdmZ5LIBoTdkgHcDzzaA6C4ABg==
+  dependencies:
+    "@peggyjs/from-mem" "1.2.1"
+    commander "^12.0.0"
+    source-map-generator "0.8.0"
+
 performance-now@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
@@ -7976,6 +7997,13 @@ semver-greatest-satisfied-range@^1.1.0:
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
   integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
 
+semver@7.6.0:
+  version "7.6.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d"
+  integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==
+  dependencies:
+    lru-cache "^6.0.0"
+
 semver@^6.0.0, semver@^6.1.2, semver@^6.3.1:
   version "6.3.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
@@ -8175,6 +8203,11 @@ sortablejs@~1.15.0:
   resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a"
   integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==
 
+source-map-generator@0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/source-map-generator/-/source-map-generator-0.8.0.tgz#10d5ca0651e2c9302ea338739cbd4408849c5d00"
+  integrity sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA==
+
 source-map-js@^1.0.1, source-map-js@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
diff --git a/hawc/apps/summary/migrations/0054_dp_calculated_columns.py b/hawc/apps/summary/migrations/0054_dp_calculated_columns.py
new file mode 100644
index 0000000000..81378caf68
--- /dev/null
+++ b/hawc/apps/summary/migrations/0054_dp_calculated_columns.py
@@ -0,0 +1,29 @@
+from django.db import migrations
+
+
+def set_right(apps, schema_editor):
+    DataPivot = apps.get_model("summary", "DataPivot")
+    updated_objs = []
+    for obj in DataPivot.objects.all():
+        obj.settings["calculated_columns"] = []
+        updated_objs.append(obj)
+    DataPivot.objects.bulk_update(updated_objs, ["settings"])
+
+
+def unset_right(apps, schema_editor):
+    DataPivot = apps.get_model("summary", "DataPivot")
+    updated_objs = []
+    for obj in DataPivot.objects.all():
+        obj.settings.pop("calculated_columns")
+        updated_objs.append(obj)
+    DataPivot.objects.bulk_update(updated_objs, ["settings"])
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("summary", "0053_right_text"),
+    ]
+
+    operations = [
+        migrations.RunPython(set_right, unset_right),
+    ]


Test patch:
diff --git a/frontend/tests/shared/parsers/format.test.js b/frontend/tests/shared/parsers/format.test.js
new file mode 100644
index 0000000000..d7474753ca
--- /dev/null
+++ b/frontend/tests/shared/parsers/format.test.js
@@ -0,0 +1,97 @@
+import assert from "assert";
+import Format from "shared/parsers/format";
+
+const obj = {one: 1, two: "two", three: "", four: null, float: 123.123456},
+    getValue = prop => obj[prop],
+    options = {getValue};
+
+describe("shared/parsers/format", function() {
+    describe("Format", function() {
+        it("handles a substitute", function() {
+            let actual = Format.parse("${one}", options),
+                expected = "1";
+            assert.ok(actual == expected);
+        });
+        it("handles multiple substitutes", function() {
+            let actual = Format.parse("${one} and ${two}", options),
+                expected = "1 and two";
+            assert.ok(actual == expected);
+        });
+        it("handles no substitutes", function() {
+            let actual = Format.parse("No numbers!", options),
+                expected = "No numbers!";
+            assert.ok(actual == expected);
+        });
+        it("handles leading and trailing literals", function() {
+            let actual = Format.parse("1 + ${one} = 2", options),
+                expected = "1 + 1 = 2";
+            assert.ok(actual == expected);
+        });
+        it("handles leading and trailing literals", function() {
+            let actual = Format.parse("1 + ${one} = 2", options),
+                expected = "1 + 1 = 2";
+            assert.ok(actual == expected);
+        });
+        it("handles escaped characters", function() {
+            let actual = Format.parse("\\${one}", options),
+                expected = "${one}";
+            assert.ok(actual == expected);
+        });
+        it("handles ternary true", function() {
+            let actual = Format.parse("match(one,1)?This is true:This is false", options),
+                expected = "This is true";
+            assert.ok(actual == expected);
+        });
+        it("handles ternary false", function() {
+            let actual = Format.parse("match(one,2)?This is true:This is false", options),
+                expected = "This is false";
+            assert.ok(actual == expected);
+        });
+        it("handles ternary integer match", function() {
+            let actual = Format.parse("match(one,1)?This is true:This is false", options),
+                expected = "This is true";
+            assert.ok(actual == expected);
+        });
+        it("handles ternary string match", function() {
+            let actual = Format.parse('match(two,"two")?This is true:This is false', options),
+                expected = "This is true";
+            assert.ok(actual == expected);
+        });
+        it("handles ternary exists for existing value", function() {
+            let actual = Format.parse("exists(one)?This is true:This is false", options),
+                expected = "This is true";
+            assert.ok(actual == expected);
+        });
+        it("handles ternary exists for empty string", function() {
+            let actual = Format.parse("exists(three)?This is true:This is false", options),
+                expected = "This is false";
+            assert.ok(actual == expected);
+        });
+        it("handles ternary exists for null value", function() {
+            let actual = Format.parse("exists(four)?This is true:This is false", options),
+                expected = "This is false";
+            assert.ok(actual == expected);
+        });
+        it("handles good round", function() {
+            assert.ok(Format.parse("round(float,0)", options) == "123");
+            assert.ok(Format.parse("round(float,1)", options) == "123.1");
+            assert.ok(Format.parse("round(float,2)", options) == "123.12");
+            assert.ok(Format.parse("***round(float,2)***", options) == "***123.12***");
+        });
+        it("handles bad round", function() {
+            assert.ok(Format.parse("round(two,0)", options) == "");
+        });
+        it("handles complex ternary", function() {
+            let actual = Format.parse(
+                    'match(one,1)?match(two,"two")?${one}=1 and ${two}=two:${two}!=two:${one}!=1',
+                    options
+                ),
+                expected = "1=1 and two=two";
+            assert.ok(actual == expected);
+        });
+        it("unable to parse bad strings", function() {
+            // substitutions need to be closed
+            assert.throws(() => Format.parse("${identifier", options));
+        });
+    });
+});
diff --git a/tests/data/api/api-dp-animal-bioassay-endpoint.json b/tests/data/api/api-dp-animal-bioassay-endpoint.json
index 9967b7d58a..e71bbf35d0 100644
--- a/tests/data/api/api-dp-animal-bioassay-endpoint.json
+++ b/tests/data/api/api-dp-animal-bioassay-endpoint.json
@@ -20,6 +20,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -69,35 +70,35 @@
     "description_settings": [
       {
         "dpe": "study",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "experiment",
-        "field_index": "experiment name",
         "field_name": "experiment name",
         "header_name": "experiment name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "animal_group",
-        "field_index": "animal group name",
         "field_name": "animal group name",
         "header_name": "animal group name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "endpoint_complete",
-        "field_index": "endpoint name",
         "field_name": "endpoint name",
         "header_name": "endpoint name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1048,4 +1049,4 @@
   "title": "data pivot - animal bioassay - endpoint",
   "url": "/summary/data-pivot/assessment/2/animal-bioassay-data-pivot-endpoint/",
   "visual_type": "Data pivot (animal bioassay)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/api/api-dp-epi.json b/tests/data/api/api-dp-epi.json
index c15a941e91..68ed6f5eb8 100644
--- a/tests/data/api/api-dp-epi.json
+++ b/tests/data/api/api-dp-epi.json
@@ -20,6 +20,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -41,55 +42,55 @@
     "description_settings": [
       {
         "dpe": "---",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
         "max_width": 150,
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "study population name",
         "field_name": "study population name",
         "header_name": "study population name",
         "header_style": "header",
         "max_width": 100,
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "outcome name",
         "field_name": "outcome name",
         "header_name": "outcome name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "comparison set name",
         "field_name": "comparison set name",
         "header_name": "comparison set name",
         "header_style": "header",
         "max_width": 100,
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "exposure group name",
         "field_name": "exposure group name",
         "header_name": "exposure group name",
         "header_style": "header",
         "max_width": 100,
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "N",
         "field_name": "N",
         "header_name": "N",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1005,4 +1006,4 @@
   "title": "data pivot - epi",
   "url": "/summary/data-pivot/assessment/2/data-pivot-epi/",
   "visual_type": "Data pivot (epidemiology)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/api/api-dp-epimeta.json b/tests/data/api/api-dp-epimeta.json
index 8f742ecc9b..0ee0ea85d2 100644
--- a/tests/data/api/api-dp-epimeta.json
+++ b/tests/data/api/api-dp-epimeta.json
@@ -20,6 +20,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -55,43 +56,43 @@
     "description_settings": [
       {
         "dpe": "study",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "meta_protocol",
-        "field_index": "protocol name",
         "field_name": "protocol name",
         "header_name": "protocol name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "meta_result",
-        "field_index": "health outcome",
         "field_name": "health outcome",
         "header_name": "health outcome",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "meta_result",
-        "field_index": "total references",
         "field_name": "total references",
         "header_name": "total references",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "meta_result",
-        "field_index": "N",
         "field_name": "N",
         "header_name": "N",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1014,4 +1015,4 @@
   "title": "data pivot - epimeta",
   "url": "/summary/data-pivot/assessment/2/data-pivot-epimeta/",
   "visual_type": "Data pivot (epidemiology meta-analysis/pooled-analysis)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/api/api-dp-invitro-endpoint-group.json b/tests/data/api/api-dp-invitro-endpoint-group.json
index 47acbcd648..ceb6c02a88 100644
--- a/tests/data/api/api-dp-invitro-endpoint-group.json
+++ b/tests/data/api/api-dp-invitro-endpoint-group.json
@@ -20,6 +20,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -55,43 +56,43 @@
     "description_settings": [
       {
         "dpe": "---",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "chemical name",
         "field_name": "chemical name",
         "header_name": "chemical name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "cell species",
         "field_name": "cell species",
         "header_name": "cell species",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "IVEndpoint name",
         "field_name": "IVEndpoint name",
         "header_name": "IVEndpoint name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "---",
         "field_name": "---",
         "header_name": "---",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1007,4 +1008,4 @@
   "title": "data pivot - invitro - endpoint group",
   "url": "/summary/data-pivot/assessment/2/data-pivot-invitro-endpoint-group/",
   "visual_type": "Data pivot (in vitro)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/api/api-dp-invitro-endpoint.json b/tests/data/api/api-dp-invitro-endpoint.json
index a3c568c0ba..e8a1b03266 100644
--- a/tests/data/api/api-dp-invitro-endpoint.json
+++ b/tests/data/api/api-dp-invitro-endpoint.json
@@ -20,6 +20,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -55,43 +56,43 @@
     "description_settings": [
       {
         "dpe": "---",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "chemical name",
         "field_name": "chemical name",
         "header_name": "chemical name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "cell species",
         "field_name": "cell species",
         "header_name": "cell species",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "IVEndpoint name",
         "field_name": "IVEndpoint name",
         "header_name": "IVEndpoint name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "---",
         "field_name": "---",
         "header_name": "---",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1007,4 +1008,4 @@
   "title": "data pivot - invitro - endpoint",
   "url": "/summary/data-pivot/assessment/2/data-pivot-invitro-endpoint/",
   "visual_type": "Data pivot (in vitro)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/api/api-dp_query-animal-bioassay-endpoint.json b/tests/data/api/api-dp_query-animal-bioassay-endpoint.json
index 6f36a4b1dc..9a6f34e989 100644
--- a/tests/data/api/api-dp_query-animal-bioassay-endpoint.json
+++ b/tests/data/api/api-dp_query-animal-bioassay-endpoint.json
@@ -28,6 +28,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -77,35 +78,35 @@
     "description_settings": [
       {
         "dpe": "study",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "experiment",
-        "field_index": "experiment name",
         "field_name": "experiment name",
         "header_name": "experiment name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "animal_group",
-        "field_index": "animal group name",
         "field_name": "animal group name",
         "header_name": "animal group name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "endpoint_complete",
-        "field_index": "endpoint name",
         "field_name": "endpoint name",
         "header_name": "endpoint name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1056,4 +1057,4 @@
   "title": "data pivot - animal bioassay - endpoint",
   "url": "/summary/data-pivot/assessment/2/animal-bioassay-data-pivot-endpoint/",
   "visual_type": "Data pivot (animal bioassay)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/api/api-dp_query-epi.json b/tests/data/api/api-dp_query-epi.json
index 7381276a52..629b1f22b8 100644
--- a/tests/data/api/api-dp_query-epi.json
+++ b/tests/data/api/api-dp_query-epi.json
@@ -26,6 +26,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -47,55 +48,55 @@
     "description_settings": [
       {
         "dpe": "---",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
         "max_width": 150,
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "study population name",
         "field_name": "study population name",
         "header_name": "study population name",
         "header_style": "header",
         "max_width": 100,
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "outcome name",
         "field_name": "outcome name",
         "header_name": "outcome name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "comparison set name",
         "field_name": "comparison set name",
         "header_name": "comparison set name",
         "header_style": "header",
         "max_width": 100,
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "exposure group name",
         "field_name": "exposure group name",
         "header_name": "exposure group name",
         "header_style": "header",
         "max_width": 100,
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "N",
         "field_name": "N",
         "header_name": "N",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1011,4 +1012,4 @@
   "title": "data pivot - epi",
   "url": "/summary/data-pivot/assessment/2/data-pivot-epi/",
   "visual_type": "Data pivot (epidemiology)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/api/api-dp_query-epimeta.json b/tests/data/api/api-dp_query-epimeta.json
index 079ab8effa..38c60df35e 100644
--- a/tests/data/api/api-dp_query-epimeta.json
+++ b/tests/data/api/api-dp_query-epimeta.json
@@ -26,6 +26,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -61,43 +62,43 @@
     "description_settings": [
       {
         "dpe": "study",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "meta_protocol",
-        "field_index": "protocol name",
         "field_name": "protocol name",
         "header_name": "protocol name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "meta_result",
-        "field_index": "health outcome",
         "field_name": "health outcome",
         "header_name": "health outcome",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "meta_result",
-        "field_index": "total references",
         "field_name": "total references",
         "header_name": "total references",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "meta_result",
-        "field_index": "N",
         "field_name": "N",
         "header_name": "N",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1020,4 +1021,4 @@
   "title": "data pivot - epimeta",
   "url": "/summary/data-pivot/assessment/2/data-pivot-epimeta/",
   "visual_type": "Data pivot (epidemiology meta-analysis/pooled-analysis)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/api/api-dp_query-invitro-endpoint-group.json b/tests/data/api/api-dp_query-invitro-endpoint-group.json
index ea67c14c61..99c9539f18 100644
--- a/tests/data/api/api-dp_query-invitro-endpoint-group.json
+++ b/tests/data/api/api-dp_query-invitro-endpoint-group.json
@@ -26,6 +26,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -61,43 +62,43 @@
     "description_settings": [
       {
         "dpe": "---",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "chemical name",
         "field_name": "chemical name",
         "header_name": "chemical name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "cell species",
         "field_name": "cell species",
         "header_name": "cell species",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "IVEndpoint name",
         "field_name": "IVEndpoint name",
         "header_name": "IVEndpoint name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "---",
         "field_name": "---",
         "header_name": "---",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1013,4 +1014,4 @@
   "title": "data pivot - invitro - endpoint group",
   "url": "/summary/data-pivot/assessment/2/data-pivot-invitro-endpoint-group/",
   "visual_type": "Data pivot (in vitro)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/api/api-dp_query-invitro-endpoint.json b/tests/data/api/api-dp_query-invitro-endpoint.json
index 8621f05348..4e3f7985ec 100644
--- a/tests/data/api/api-dp_query-invitro-endpoint.json
+++ b/tests/data/api/api-dp_query-invitro-endpoint.json
@@ -26,6 +26,7 @@
       "field_name": "---",
       "header_name": ""
     },
+    "calculated_columns": [],
     "dataline_settings": [
       {
         "conditional_formatting": [],
@@ -61,43 +62,43 @@
     "description_settings": [
       {
         "dpe": "---",
-        "field_index": "study name",
         "field_name": "study name",
         "header_name": "study name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "chemical name",
         "field_name": "chemical name",
         "header_name": "chemical name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "cell species",
         "field_name": "cell species",
         "header_name": "cell species",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "IVEndpoint name",
         "field_name": "IVEndpoint name",
         "header_name": "IVEndpoint name",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       },
       {
         "dpe": "---",
-        "field_index": "---",
         "field_name": "---",
         "header_name": "---",
         "header_style": "header",
-        "text_style": "base"
+        "text_style": "base",
+        "to_right": false
       }
     ],
     "filters": [
@@ -1013,4 +1014,4 @@
   "title": "data pivot - invitro - endpoint",
   "url": "/summary/data-pivot/assessment/2/data-pivot-invitro-endpoint/",
   "visual_type": "Data pivot (in vitro)"
-}
\ No newline at end of file
+}
diff --git a/tests/data/fixtures/db.yaml b/tests/data/fixtures/db.yaml
index 97f25b46c3..54fceca723 100644
--- a/tests/data/fixtures/db.yaml
+++ b/tests/data/fixtures/db.yaml
@@ -10325,11 +10325,11 @@
       "---", "rect_style": "---"}]}, "dataline_settings": [{"low_field_name": "percent control low", "high_field_name": "percent control high", "header_name": "percent
       control low", "marker_style": "base", "conditional_formatting": []}], "datapoint_settings": [{"field_name": "percent control mean", "header_name": "percent
       control mean", "marker_style": "base", "dpe": "---", "conditional_formatting": []}], "description_settings": [{"field_name": "study name", "header_name": "study
-      name", "header_style": "header", "text_style": "base", "dpe": "study", "field_index": "study name"}, {"field_name": "experiment name", "header_name": "experiment
-      name", "header_style": "header", "text_style": "base", "dpe": "experiment", "field_index": "experiment name"}, {"field_name": "animal group name", "header_name":
-      "animal group name", "header_style": "header", "text_style": "base", "dpe": "animal_group", "field_index": "animal group name"}, {"field_name": "endpoint name",
-      "header_name": "endpoint name", "header_style": "header", "text_style": "base", "dpe": "endpoint_complete", "field_index": "endpoint name"}, {"field_name":
-      "dose", "header_name": "dose (mg/kg/d)", "header_style": "header", "text_style": "base", "dpe": "---", "field_index": "dose"}], "barchart": {"dpe": "---", "field_name":
+      name", "header_style": "header", "text_style": "base", "dpe": "study", "to_right": false}, {"field_name": "experiment name", "header_name": "experiment
+      name", "header_style": "header", "text_style": "base", "dpe": "experiment", "to_right": false}, {"field_name": "animal group name", "header_name":
+      "animal group name", "header_style": "header", "text_style": "base", "dpe": "animal_group", "to_right": false}, {"field_name": "endpoint name",
+      "header_name": "endpoint name", "header_style": "header", "text_style": "base", "dpe": "endpoint_complete", "to_right": false}, {"field_name":
+      "dose", "header_name": "dose (mg/kg/d)", "header_style": "header", "text_style": "base", "dpe": "---", "to_right": false}], "barchart": {"dpe": "---", "field_name":
       "---", "error_low_field_name": "---", "error_high_field_name": "---", "header_name": "", "error_header_name": "", "bar_style": "base", "error_marker_style":
       "base", "conditional_formatting": [], "error_show_tails": true}, "spacers": [{"index": -1, "show_line": true, "line_style": "reference line", "extra_space":
       false}], "sorts": [{"field_name": "---", "order": "asc", "custom": null}, {"field_name": "---", "order": "asc", "custom": null}], "filters": [{"field_name":
@@ -10415,7 +10415,7 @@
       1, "pattern": "solid", "pattern_fill": "#ffffff"}, {"name": "blue", "fill": "#3aa4e5", "fill-opacity": 0.8, "stroke": "#006dbe", "stroke-width": 1, "pattern":
       "solid", "pattern_fill": "#ffffff"}, {"name": "orange", "fill": "#ffb100", "fill-opacity": 0.8, "stroke": "#dc8f00", "stroke-width": 1, "pattern": "solid",
       "pattern_fill": "#ffffff"}, {"name": "purple", "fill": "#b82cff", "fill-opacity": 0.8, "stroke": "#5e5e5e", "stroke-width": 1, "pattern": "solid", "pattern_fill":
-      "#ffffff"}, {"name": "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}}
+      "#ffffff"}, {"name": "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}, "calculated_columns": []}
     caption: <p>caption</p>
     published: true
     created: 2020-05-08 17:48:36.143248+00:00
@@ -10439,11 +10439,8 @@
       2", "marker_style": "base", "dpe": "endpoint_complete", "conditional_formatting": []}, {"field_name": "Dose 3", "header_name": "Dose 3", "marker_style": "base",
       "dpe": "endpoint_complete", "conditional_formatting": []}, {"field_name": "Dose 4", "header_name": "Dose 4", "marker_style": "base", "dpe": "endpoint_complete",
       "conditional_formatting": []}, {"field_name": "Dose 5", "header_name": "Dose 5", "marker_style": "base", "dpe": "endpoint_complete", "conditional_formatting":
-      []}], "description_settings": [{"field_name": "study name", "header_name": "study name", "header_style": "header", "text_style": "base", "dpe": "study", "field_index":
-      "study name"}, {"field_name": "experiment name", "header_name": "experiment name", "header_style": "header", "text_style": "base", "dpe": "experiment", "field_index":
-      "experiment name"}, {"field_name": "animal group name", "header_name": "animal group name", "header_style": "header", "text_style": "base", "dpe": "animal_group",
-      "field_index": "animal group name"}, {"field_name": "endpoint name", "header_name": "endpoint name", "header_style": "header", "text_style": "base", "dpe":
-      "endpoint_complete", "field_index": "endpoint name"}], "barchart": {"dpe": "---", "field_name": "---", "error_low_field_name": "---", "error_high_field_name":
+      []}], "description_settings": [{"field_name": "study name", "header_name": "study name", "header_style": "header", "text_style": "base", "dpe": "study", "to_right": false}, {"field_name": "experiment name", "header_name": "experiment name", "header_style": "header", "text_style": "base", "dpe": "experiment", "to_right": false}, {"field_name": "animal group name", "header_name": "animal group name", "header_style": "header", "text_style": "base", "dpe": "animal_group", "to_right": false}, {"field_name": "endpoint name", "header_name": "endpoint name", "header_style": "header", "text_style": "base", "dpe":
+      "endpoint_complete", "to_right": false}], "barchart": {"dpe": "---", "field_name": "---", "error_low_field_name": "---", "error_high_field_name":
       "---", "header_name": "", "error_header_name": "", "bar_style": "base", "error_marker_style": "base", "conditional_formatting": [], "error_show_tails": true},
       "spacers": [{"index": -1, "show_line": true, "line_style": "reference line", "extra_space": false}], "sorts": [{"field_name": "---", "order": "asc", "custom":
       null}, {"field_name": "---", "order": "asc", "custom": null}], "filters": [{"field_name": "---", "quantifier": "contains", "value": ""}, {"field_name": "---",
@@ -10529,7 +10526,7 @@
       "solid", "pattern_fill": "#ffffff"}, {"name": "blue", "fill": "#3aa4e5", "fill-opacity": 0.8, "stroke": "#006dbe", "stroke-width": 1, "pattern": "solid", "pattern_fill":
       "#ffffff"}, {"name": "orange", "fill": "#ffb100", "fill-opacity": 0.8, "stroke": "#dc8f00", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"},
       {"name": "purple", "fill": "#b82cff", "fill-opacity": 0.8, "stroke": "#5e5e5e", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}, {"name":
-      "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}}
+      "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}, "calculated_columns": []}
     caption: ''
     published: true
     created: 2020-05-08 17:52:21.928839+00:00
@@ -10549,11 +10546,11 @@
       []}], "datapoint_settings": [{"field_name": "estimate", "header_name": "estimate", "marker_style": "base", "dpe": "---", "conditional_formatting": []}, {"field_name":
       "---", "header_name": "---", "marker_style": "base", "dpe": "---", "conditional_formatting": []}, {"field_name": "---", "header_name": "---", "marker_style":
       "base", "dpe": "---", "conditional_formatting": []}], "description_settings": [{"field_name": "study name", "header_name": "study name", "header_style": "header",
-      "text_style": "base", "dpe": "study", "field_index": "study name"}, {"field_name": "protocol name", "header_name": "protocol name", "header_style": "header",
-      "text_style": "base", "dpe": "meta_protocol", "field_index": "protocol name"}, {"field_name": "health outcome", "header_name": "health outcome", "header_style":
-      "header", "text_style": "base", "dpe": "meta_result", "field_index": "health outcome"}, {"field_name": "total references", "header_name": "total references",
-      "header_style": "header", "text_style": "base", "dpe": "meta_result", "field_index": "total references"}, {"field_name": "N", "header_name": "N", "header_style":
-      "header", "text_style": "base", "dpe": "meta_result", "field_index": "N"}], "barchart": {"dpe": "---", "field_name": "---", "error_low_field_name": "---", "error_high_field_name":
+      "text_style": "base", "dpe": "study", "to_right": false}, {"field_name": "protocol name", "header_name": "protocol name", "header_style": "header",
+      "text_style": "base", "dpe": "meta_protocol", "to_right": false}, {"field_name": "health outcome", "header_name": "health outcome", "header_style":
+      "header", "text_style": "base", "dpe": "meta_result", "to_right": false}, {"field_name": "total references", "header_name": "total references",
+      "header_style": "header", "text_style": "base", "dpe": "meta_result", "to_right": false}, {"field_name": "N", "header_name": "N", "header_style":
+      "header", "text_style": "base", "dpe": "meta_result", "to_right": false}], "barchart": {"dpe": "---", "field_name": "---", "error_low_field_name": "---", "error_high_field_name":
       "---", "header_name": "", "error_header_name": "", "bar_style": "base", "error_marker_style": "base", "conditional_formatting": [], "error_show_tails": true},
       "spacers": [{"index": -1, "show_line": true, "line_style": "reference line", "extra_space": false}], "sorts": [{"field_name": "---", "order": "asc", "custom":
       null}, {"field_name": "---", "order": "asc", "custom": null}], "filters": [{"field_name": "---", "quantifier": "contains", "value": ""}, {"field_name": "---",
@@ -10639,7 +10636,7 @@
       "solid", "pattern_fill": "#ffffff"}, {"name": "blue", "fill": "#3aa4e5", "fill-opacity": 0.8, "stroke": "#006dbe", "stroke-width": 1, "pattern": "solid", "pattern_fill":
       "#ffffff"}, {"name": "orange", "fill": "#ffb100", "fill-opacity": 0.8, "stroke": "#dc8f00", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"},
       {"name": "purple", "fill": "#b82cff", "fill-opacity": 0.8, "stroke": "#5e5e5e", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}, {"name":
-      "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}}
+      "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}, "calculated_columns": []}
     caption: ''
     published: true
     created: 2020-05-09 03:22:01.190725+00:00
@@ -10657,12 +10654,12 @@
       "base", "line_style": "---", "rect_style": "---"}]}, "dataline_settings": [{"low_field_name": "---", "high_field_name": "---", "header_name": "---", "marker_style":
       "base", "conditional_formatting": []}], "datapoint_settings": [{"field_name": "estimate", "header_name": "estimate", "marker_style": "base", "dpe": "---", "conditional_formatting":
       []}], "description_settings": [{"field_name": "study name", "header_name": "study name", "header_style": "header", "text_style": "base", "dpe": "---", "max_width":
-      150, "field_index": "study name"}, {"field_name": "study population name", "header_name": "study population name", "header_style": "header", "text_style": "base",
-      "dpe": "---", "max_width": 100, "field_index": "study population name"}, {"field_name": "outcome name", "header_name": "outcome name", "header_style": "header",
-      "text_style": "base", "dpe": "---", "field_index": "outcome name"}, {"field_name": "comparison set name", "header_name": "comparison set name", "header_style":
-      "header", "text_style": "base", "dpe": "---", "max_width": 100, "field_index": "comparison set name"}, {"field_name": "exposure group name", "header_name":
-      "exposure group name", "header_style": "header", "text_style": "base", "dpe": "---", "max_width": 100, "field_index": "exposure group name"}, {"field_name":
-      "N", "header_name": "N", "header_style": "header", "text_style": "base", "dpe": "---", "field_index": "N"}], "barchart": {"dpe": "---", "field_name": "---",
+      150, "to_right": false}, {"field_name": "study population name", "header_name": "study population name", "header_style": "header", "text_style": "base",
+      "dpe": "---", "max_width": 100, "to_right": false}, {"field_name": "outcome name", "header_name": "outcome name", "header_style": "header",
+      "text_style": "base", "dpe": "---", "to_right": false}, {"field_name": "comparison set name", "header_name": "comparison set name", "header_style":
+      "header", "text_style": "base", "dpe": "---", "max_width": 100, "to_right": false}, {"field_name": "exposure group name", "header_name":
+      "exposure group name", "header_style": "header", "text_style": "base", "dpe": "---", "max_width": 100, "to_right": false}, {"field_name":
+      "N", "header_name": "N", "header_style": "header", "text_style": "base", "dpe": "---", "to_right": false}], "barchart": {"dpe": "---", "field_name": "---",
       "error_low_field_name": "---", "error_high_field_name": "---", "header_name": "", "error_header_name": "", "bar_style": "base", "error_marker_style": "base",
       "conditional_formatting": [], "error_show_tails": true}, "spacers": [{"index": -1, "show_line": true, "line_style": "reference line", "extra_space": false}],
       "sorts": [{"field_name": "---", "order": "asc", "custom": null}, {"field_name": "---", "order": "asc", "custom": null}], "filters": [{"field_name": "---", "quantifier":
@@ -10748,7 +10745,7 @@
       1, "pattern": "solid", "pattern_fill": "#ffffff"}, {"name": "blue", "fill": "#3aa4e5", "fill-opacity": 0.8, "stroke": "#006dbe", "stroke-width": 1, "pattern":
       "solid", "pattern_fill": "#ffffff"}, {"name": "orange", "fill": "#ffb100", "fill-opacity": 0.8, "stroke": "#dc8f00", "stroke-width": 1, "pattern": "solid",
       "pattern_fill": "#ffffff"}, {"name": "purple", "fill": "#b82cff", "fill-opacity": 0.8, "stroke": "#5e5e5e", "stroke-width": 1, "pattern": "solid", "pattern_fill":
-      "#ffffff"}, {"name": "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}}
+      "#ffffff"}, {"name": "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}, "calculated_columns": []}
     caption: <p>Example.</p>
     published: true
     created: 2020-05-11 02:26:51.725098+00:00
@@ -10767,11 +10764,11 @@
       "minimum dose", "marker_style": "base", "conditional_formatting": []}], "datapoint_settings": [{"field_name": "Dose 1", "header_name": "Dose 1", "marker_style":
       "base", "dpe": "---", "conditional_formatting": []}, {"field_name": "Dose 2", "header_name": "Dose 2", "marker_style": "base", "dpe": "---", "conditional_formatting":
       []}, {"field_name": "Dose 3", "header_name": "Dose 3", "marker_style": "base", "dpe": "---", "conditional_formatting": []}], "description_settings": [{"field_name":
-      "study name", "header_name": "study name", "header_style": "header", "text_style": "base", "dpe": "---", "field_index": "study name"}, {"field_name": "chemical
-      name", "header_name": "chemical name", "header_style": "header", "text_style": "base", "dpe": "---", "field_index": "chemical name"}, {"field_name": "cell species",
-      "header_name": "cell species", "header_style": "header", "text_style": "base", "dpe": "---", "field_index": "cell species"}, {"field_name": "IVEndpoint name",
-      "header_name": "IVEndpoint name", "header_style": "header", "text_style": "base", "dpe": "---", "field_index": "IVEndpoint name"}, {"field_name": "---", "header_name":
-      "---", "header_style": "header", "text_style": "base", "dpe": "---", "field_index": "---"}], "barchart": {"dpe": "---", "field_name": "---", "error_low_field_name":
+      "study name", "header_name": "study name", "header_style": "header", "text_style": "base", "dpe": "---", "to_right": false}, {"field_name": "chemical
+      name", "header_name": "chemical name", "header_style": "header", "text_style": "base", "dpe": "---", "to_right": false}, {"field_name": "cell species",
+      "header_name": "cell species", "header_style": "header", "text_style": "base", "dpe": "---", "to_right": false}, {"field_name": "IVEndpoint name",
+      "header_name": "IVEndpoint name", "header_style": "header", "text_style": "base", "dpe": "---", "to_right": false}, {"field_name": "---", "header_name":
+      "---", "header_style": "header", "text_style": "base", "dpe": "---", "to_right": false}], "barchart": {"dpe": "---", "field_name": "---", "error_low_field_name":
       "---", "error_high_field_name": "---", "header_name": "", "error_header_name": "", "bar_style": "base", "error_marker_style": "base", "conditional_formatting":
       [], "error_show_tails": true}, "spacers": [{"index": -1, "show_line": true, "line_style": "reference line", "extra_space": false}], "sorts": [{"field_name":
       "---", "order": "asc", "custom": null}, {"field_name": "---", "order": "asc", "custom": null}], "filters": [{"field_name": "---", "quantifier": "contains",
@@ -10857,7 +10854,7 @@
       1, "pattern": "solid", "pattern_fill": "#ffffff"}, {"name": "blue", "fill": "#3aa4e5", "fill-opacity": 0.8, "stroke": "#006dbe", "stroke-width": 1, "pattern":
       "solid", "pattern_fill": "#ffffff"}, {"name": "orange", "fill": "#ffb100", "fill-opacity": 0.8, "stroke": "#dc8f00", "stroke-width": 1, "pattern": "solid",
       "pattern_fill": "#ffffff"}, {"name": "purple", "fill": "#b82cff", "fill-opacity": 0.8, "stroke": "#5e5e5e", "stroke-width": 1, "pattern": "solid", "pattern_fill":
-      "#ffffff"}, {"name": "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}}
+      "#ffffff"}, {"name": "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}, "calculated_columns": []}
     caption: <p>demo.</p>
     published: true
     created: 2020-05-11 04:10:29.880052+00:00
@@ -10876,11 +10873,11 @@
       "base", "conditional_formatting": []}], "datapoint_settings": [{"field_name": "dose", "header_name": "dose", "marker_style": "base", "dpe": "---", "conditional_formatting":
       []}, {"field_name": "---", "header_name": "---", "marker_style": "base", "dpe": "---", "conditional_formatting": []}, {"field_name": "---", "header_name": "---",
       "marker_style": "base", "dpe": "---", "conditional_formatting": []}], "description_settings": [{"field_name": "study name", "header_name": "study name", "header_style":
-      "header", "text_style": "base", "dpe": "---", "field_index": "study name"}, {"field_name": "chemical name", "header_name": "chemical name", "header_style":
-      "header", "text_style": "base", "dpe": "---", "field_index": "chemical name"}, {"field_name": "cell species", "header_name": "cell species", "header_style":
-      "header", "text_style": "base", "dpe": "---", "field_index": "cell species"}, {"field_name": "IVEndpoint name", "header_name": "IVEndpoint name", "header_style":
-      "header", "text_style": "base", "dpe": "---", "field_index": "IVEndpoint name"}, {"field_name": "---", "header_name": "---", "header_style": "header", "text_style":
-      "base", "dpe": "---", "field_index": "---"}], "barchart": {"dpe": "---", "field_name": "---", "error_low_field_name": "---", "error_high_field_name": "---",
+      "header", "text_style": "base", "dpe": "---", "to_right": false}, {"field_name": "chemical name", "header_name": "chemical name", "header_style":
+      "header", "text_style": "base", "dpe": "---", "to_right": false}, {"field_name": "cell species", "header_name": "cell species", "header_style":
+      "header", "text_style": "base", "dpe": "---", "to_right": false}, {"field_name": "IVEndpoint name", "header_name": "IVEndpoint name", "header_style":
+      "header", "text_style": "base", "dpe": "---", "to_right": false}, {"field_name": "---", "header_name": "---", "header_style": "header", "text_style":
+      "base", "dpe": "---", "to_right": false}], "barchart": {"dpe": "---", "field_name": "---", "error_low_field_name": "---", "error_high_field_name": "---",
       "header_name": "", "error_header_name": "", "bar_style": "base", "error_marker_style": "base", "conditional_formatting": [], "error_show_tails": true}, "spacers":
       [{"index": -1, "show_line": true, "line_style": "reference line", "extra_space": false}], "sorts": [{"field_name": "---", "order": "asc", "custom": null}, {"field_name":
       "---", "order": "asc", "custom": null}], "filters": [{"field_name": "---", "quantifier": "contains", "value": ""}, {"field_name": "---", "quantifier": "contains",
@@ -10966,7 +10963,7 @@
       "fill-opacity": 0.8, "stroke": "#006dbe", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}, {"name": "orange", "fill": "#ffb100", "fill-opacity":
       0.8, "stroke": "#dc8f00", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}, {"name": "purple", "fill": "#b82cff", "fill-opacity": 0.8, "stroke":
       "#5e5e5e", "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}, {"name": "fuschia", "fill": "#d4266e", "fill-opacity": 0.8, "stroke": "#ab006c",
-      "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}}
+      "stroke-width": 1, "pattern": "solid", "pattern_fill": "#ffffff"}]}, "calculated_columns": []}
     caption: <p>demo.</p>
     published: true
     created: 2020-05-11 04:12:54.866928+00:00