diff --git a/frontend/src/metabase/css/dashboard.css b/frontend/src/metabase/css/dashboard.css
index 3dd80b94d77c0f7a7c66d7be72783e5c4e23fc81..4c0797d3742f1d18b54f5d520faa8807edf5d0d1 100644
--- a/frontend/src/metabase/css/dashboard.css
+++ b/frontend/src/metabase/css/dashboard.css
@@ -332,6 +332,12 @@
   stroke-opacity: 1 !important;
 }
 
+.dc-chart circle.bubble {
+    fill-opacity: 0.80;
+    stroke-width: 1;
+    stroke: white;
+}
+
 .enable-dots .dc-tooltip circle.dot:hover,
 .enable-dots .dc-tooltip circle.dot.hover {
   fill: currentColor;
@@ -399,7 +405,13 @@
 .mute-2 .sub._2 .dot,
 .mute-3 .sub._3 .dot,
 .mute-4 .sub._4 .dot,
-.mute-5 .sub._5 .dot { opacity: 0.25; }
+.mute-5 .sub._5 .dot,
+.mute-0 .sub._0 .bubble,
+.mute-1 .sub._1 .bubble,
+.mute-2 .sub._2 .bubble,
+.mute-3 .sub._3 .bubble,
+.mute-4 .sub._4 .bubble,
+.mute-5 .sub._5 .bubble { opacity: 0.25; }
 
 
 .mute-yl .dc-chart .axis.y,
diff --git a/frontend/src/metabase/lib/formatting.js b/frontend/src/metabase/lib/formatting.js
index 3f0e5e66a2384183e4baf2e489fcfcba3a912cfb..346193d782b9f080913dc1bf284d5fd497a97de6 100644
--- a/frontend/src/metabase/lib/formatting.js
+++ b/frontend/src/metabase/lib/formatting.js
@@ -49,7 +49,7 @@ function formatMajorMinor(major, minor, options = {}) {
 }
 
 function formatTimeWithUnit(value, unit, options = {}) {
-    let m = parseTimestamp(value);
+    let m = parseTimestamp(value, unit);
     if (!m.isValid()) {
         return String(value);
     }
@@ -67,7 +67,7 @@ function formatTimeWithUnit(value, unit, options = {}) {
                 <div><span className="text-bold">{m.format("MMMM")}</span> {m.format("YYYY")}</div> :
                 m.format("MMMM") + " " + m.format("YYYY");
         case "year": // 2015
-            return String(value);
+            return m.format("YYYY");
         case "quarter": // Q1 - 2015
             return formatMajorMinor(m.format("[Q]Q"), m.format("YYYY"), { ...options, majorWidth: 0 });
         case "hour-of-day": // 12 AM
@@ -97,7 +97,7 @@ export function formatValue(value, options = {}) {
     } else if (column && column.unit != null) {
         return formatTimeWithUnit(value, column.unit, options);
     } else if (isDate(column) || moment.isDate(value) || moment.isMoment(value) || moment(value, ["YYYY-MM-DD'T'HH:mm:ss.SSSZ"], true).isValid()) {
-        return parseTimestamp(value).format("LLLL");
+        return parseTimestamp(value, column && column.unit).format("LLLL");
     } else if (typeof value === "string") {
         return value;
     } else if (typeof value === "number") {
diff --git a/frontend/src/metabase/lib/time.js b/frontend/src/metabase/lib/time.js
index 532a19c1919d4ee1c25d7cdbae0431010722197f..dc544909fc12a1a32a6e8e63df7652e5d41628d7 100644
--- a/frontend/src/metabase/lib/time.js
+++ b/frontend/src/metabase/lib/time.js
@@ -2,11 +2,14 @@ import moment from "moment";
 
 // only attempt to parse the timezone if we're sure we have one (either Z or ±hh:mm)
 // moment normally interprets the DD in YYYY-MM-DD as an offset :-/
-export function parseTimestamp(value) {
+export function parseTimestamp(value, unit) {
     if (moment.isMoment(value)) {
         return value;
     } else if (typeof value === "string" && /(Z|[+-]\d\d:\d\d)$/.test(value)) {
         return moment.parseZone(value);
+    } else if (unit === "year") {
+        // workaround for https://github.com/metabase/metabase/issues/1992
+        return moment().year(value).startOf("year");
     } else {
         return moment.utc(value);
     }
diff --git a/frontend/src/metabase/lib/visualization_settings.js b/frontend/src/metabase/lib/visualization_settings.js
index 8518f90adc2d4e01885b71565038c2fe931dde04..1fa6cc09c9050fc270f8cc7042d3ada11d08aa4b 100644
--- a/frontend/src/metabase/lib/visualization_settings.js
+++ b/frontend/src/metabase/lib/visualization_settings.js
@@ -10,14 +10,20 @@ import {
 
 import { isNumeric, isDate, isMetric, isDimension, hasLatitudeAndLongitudeColumns } from "metabase/lib/schema_metadata";
 import Query from "metabase/lib/query";
+import { capitalize } from "metabase/lib/formatting";
 
 import { getCardColors, getFriendlyName } from "metabase/visualizations/lib/utils";
 
+import { dimensionIsTimeseries } from "metabase/visualizations/lib/timeseries";
+import { dimensionIsNumeric } from "metabase/visualizations/lib/numeric";
+
 import ChartSettingInput from "metabase/visualizations/components/settings/ChartSettingInput.jsx";
 import ChartSettingInputNumeric from "metabase/visualizations/components/settings/ChartSettingInputNumeric.jsx";
 import ChartSettingSelect from "metabase/visualizations/components/settings/ChartSettingSelect.jsx";
 import ChartSettingToggle from "metabase/visualizations/components/settings/ChartSettingToggle.jsx";
+import ChartSettingFieldPicker from "metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx";
 import ChartSettingFieldsPicker from "metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx";
+import ChartSettingColorPicker from "metabase/visualizations/components/settings/ChartSettingColorPicker.jsx";
 import ChartSettingColorsPicker from "metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx";
 import ChartSettingOrderedFields from "metabase/visualizations/components/settings/ChartSettingOrderedFields.jsx";
 
@@ -50,7 +56,24 @@ function getSeriesTitles([{ data: { rows, cols } }], vizSettings) {
     }
 }
 
-function getDefaultDimensionsAndMetrics([{ data: { cols, rows } }]) {
+function getDefaultColumns(series) {
+    if (series[0].card.display === "scatter") {
+        return getDefaultScatterColumns(series);
+    } else {
+        return getDefaultLineAreaBarColumns(series);
+    }
+}
+
+function getDefaultScatterColumns([{ data: { cols, rows } }]) {
+    // TODO
+    return {
+        dimensions: [null],
+        metrics: [null],
+        bubble: null
+    };
+}
+
+function getDefaultLineAreaBarColumns([{ data: { cols, rows } }]) {
     let type = getChartTypeFromData(cols, rows, false);
     switch (type) {
         case DIMENSION_DIMENSION_METRIC:
@@ -108,24 +131,36 @@ function getOptionFromColumn(col) {
 
 // const CURRENCIES = ["afn", "ars", "awg", "aud", "azn", "bsd", "bbd", "byr", "bzd", "bmd", "bob", "bam", "bwp", "bgn", "brl", "bnd", "khr", "cad", "kyd", "clp", "cny", "cop", "crc", "hrk", "cup", "czk", "dkk", "dop", "xcd", "egp", "svc", "eek", "eur", "fkp", "fjd", "ghc", "gip", "gtq", "ggp", "gyd", "hnl", "hkd", "huf", "isk", "inr", "idr", "irr", "imp", "ils", "jmd", "jpy", "jep", "kes", "kzt", "kpw", "krw", "kgs", "lak", "lvl", "lbp", "lrd", "ltl", "mkd", "myr", "mur", "mxn", "mnt", "mzn", "nad", "npr", "ang", "nzd", "nio", "ngn", "nok", "omr", "pkr", "pab", "pyg", "pen", "php", "pln", "qar", "ron", "rub", "shp", "sar", "rsd", "scr", "sgd", "sbd", "sos", "zar", "lkr", "sek", "chf", "srd", "syp", "tzs", "twd", "thb", "ttd", "try", "trl", "tvd", "ugx", "uah", "gbp", "usd", "uyu", "uzs", "vef", "vnd", "yer", "zwd"];
 
+import { normal } from "metabase/lib/colors";
+
+const isAnyField = () => true;
+
 const SETTINGS = {
+    "graph._dimension_filter": {
+        getDefault: ([{ card }]) => card.display === "scatter" ? isAnyField : isDimension
+    },
+    "graph._metric_filter": {
+        getDefault: ([{ card }]) => card.display === "scatter" ? isNumeric : isMetric
+    },
     "graph.dimensions": {
         section: "Data",
         title: "X-axis",
         widget: ChartSettingFieldsPicker,
         isValid: ([{ card, data }], vizSettings) =>
-            columnsAreValid(card.visualization_settings["graph.dimensions"], data, isDimension) &&
-            columnsAreValid(card.visualization_settings["graph.metrics"], data, isMetric),
+            columnsAreValid(card.visualization_settings["graph.dimensions"], data, vizSettings["graph._dimension_filter"]) &&
+            columnsAreValid(card.visualization_settings["graph.metrics"], data, vizSettings["graph._metric_filter"]),
         getDefault: (series, vizSettings) =>
-            getDefaultDimensionsAndMetrics(series).dimensions,
+            getDefaultColumns(series).dimensions,
         getProps: ([{ card, data }], vizSettings) => {
             const value = vizSettings["graph.dimensions"];
-            const options = data.cols.filter(isDimension).map(getOptionFromColumn);
+            const options = data.cols.filter(vizSettings["graph._dimension_filter"]).map(getOptionFromColumn);
             return {
                 options,
-                addAnother: (options.length > value.length && value.length < 2) ? "Add a series breakout..." : null
+                addAnother: (options.length > value.length && value.length < 2 && vizSettings["graph.metrics"].length < 2) ?
+                    "Add a series breakout..." : null
             };
         },
+        readDependencies: ["graph._dimension_filter", "graph._metric_filter"],
         writeDependencies: ["graph.metrics"]
     },
     "graph.metrics": {
@@ -133,16 +168,35 @@ const SETTINGS = {
         title: "Y-axis",
         widget: ChartSettingFieldsPicker,
         isValid: ([{ card, data }], vizSettings) =>
-            columnsAreValid(card.visualization_settings["graph.dimensions"], data, isDimension) &&
-            columnsAreValid(card.visualization_settings["graph.metrics"], data, isMetric),
+            columnsAreValid(card.visualization_settings["graph.dimensions"], data, vizSettings["graph._dimension_filter"]) &&
+            columnsAreValid(card.visualization_settings["graph.metrics"], data, vizSettings["graph._metric_filter"]),
         getDefault: (series, vizSettings) =>
-            getDefaultDimensionsAndMetrics(series).metrics,
+            getDefaultColumns(series).metrics,
         getProps: ([{ card, data }], vizSettings) => {
             const value = vizSettings["graph.dimensions"];
-            const options = data.cols.filter(isMetric).map(getOptionFromColumn);
+            const options = data.cols.filter(vizSettings["graph._metric_filter"]).map(getOptionFromColumn);
+            return {
+                options,
+                addAnother: options.length > value.length && vizSettings["graph.dimensions"].length < 2 ?
+                    "Add another series..." : null
+            };
+        },
+        readDependencies: ["graph._dimension_filter", "graph._metric_filter"],
+        writeDependencies: ["graph.dimensions"]
+    },
+    "scatter.bubble": {
+        section: "Data",
+        title: "Bubble size",
+        widget: ChartSettingFieldPicker,
+        isValid: ([{ card, data }], vizSettings) =>
+            columnsAreValid([card.visualization_settings["scatter.bubble"]], data, isNumeric),
+        getDefault: (series) =>
+            getDefaultColumns(series).bubble,
+        getProps: ([{ card, data }], vizSettings, onChange) => {
+            const options = data.cols.filter(isNumeric).map(getOptionFromColumn);
             return {
                 options,
-                addAnother: options.length > value.length ? "Add another series..." : null
+                onRemove: vizSettings["scatter.bubble"] ? () => onChange(null) : null
             };
         },
         writeDependencies: ["graph.dimensions"]
@@ -177,8 +231,84 @@ const SETTINGS = {
             (card.display === "area" && vizSettings["graph.metrics"].length > 1)
         )
     },
+    "graph.show_goal": {
+        section: "Display",
+        title: "Show goal",
+        widget: ChartSettingToggle,
+        default: false
+    },
+    "graph.goal_value": {
+        section: "Display",
+        title: "Goal value",
+        widget: ChartSettingInputNumeric,
+        default: 0,
+        getHidden: (series, vizSettings) => vizSettings["graph.show_goal"] !== true,
+        readDependencies: ["graph.show_goal"]
+    },
+    "line.missing": {
+        section: "Display",
+        title: "Replace missing values with",
+        widget: ChartSettingSelect,
+        default: "interpolate",
+        getProps: (series, vizSettings) => ({
+            options: [
+                { name: "Zero", value: "zero" },
+                { name: "Nothing", value: "none" },
+                { name: "Linear Interpolated", value: "interpolate" },
+            ]
+        })
+    },
+    "graph.x_axis._is_timeseries": {
+        readDependencies: ["graph.dimensions"],
+        getDefault: ([{ data }], vizSettings) =>
+            dimensionIsTimeseries(data, _.findIndex(data.cols, (c) => c.name === vizSettings["graph.dimensions"].filter(d => d)[0]))
+    },
+    "graph.x_axis._is_numeric": {
+        readDependencies: ["graph.dimensions"],
+        getDefault: ([{ data }], vizSettings) =>
+            dimensionIsNumeric(data, _.findIndex(data.cols, (c) => c.name === vizSettings["graph.dimensions"].filter(d => d)[0]))
+    },
+    "graph.x_axis.scale": {
+        section: "Axes",
+        title: "X-axis scale",
+        widget: ChartSettingSelect,
+        default: "ordinal",
+        readDependencies: ["graph.x_axis._is_timeseries", "graph.x_axis._is_numeric"],
+        getDefault: (series, vizSettings) =>
+            vizSettings["graph.x_axis._is_timeseries"] ? "timeseries" :
+            vizSettings["graph.x_axis._is_numeric"] ? "linear" :
+            "ordinal",
+        getProps: (series, vizSettings) => {
+            const options = [];
+            if (vizSettings["graph.x_axis._is_timeseries"]) {
+                options.push({ name: "Timeseries", value: "timeseries" });
+            }
+            if (vizSettings["graph.x_axis._is_numeric"]) {
+                options.push({ name: "Linear", value: "linear" });
+                options.push({ name: "Power", value: "pow" });
+                options.push({ name: "Log", value: "log" });
+            }
+            options.push({ name: "Ordinal", value: "ordinal" });
+            return { options };
+        }
+    },
+    "graph.y_axis.scale": {
+        section: "Axes",
+        title: "Y-axis scale",
+        widget: ChartSettingSelect,
+        default: "linear",
+        getProps: (series, vizSettings) => ({
+            options: [
+                { name: "Linear", value: "linear" },
+                { name: "Power", value: "pow" },
+                { name: "Log", value: "log" }
+            ]
+        })
+    },
     "graph.colors": {
         section: "Display",
+        getTitle: ([{ card: { display } }]) =>
+            capitalize(display === "scatter" ? "bubble" : display) + " Colors",
         widget: ChartSettingColorsPicker,
         readDependencies: ["graph.dimensions", "graph.metrics"],
         getDefault: ([{ card, data }], vizSettings) => {
@@ -297,16 +427,21 @@ const SETTINGS = {
         }),
     },
     "pie.show_legend": {
-        section: "Legend",
+        section: "Display",
         title: "Show legend",
         widget: ChartSettingToggle
     },
     "pie.show_legend_perecent": {
-        section: "Legend",
+        section: "Display",
         title: "Show percentages in legend",
         widget: ChartSettingToggle,
         default: true
     },
+    "pie.slice_threshold": {
+        section: "Display",
+        title: "Minimum slice percentage",
+        widget: ChartSettingInputNumeric
+    },
     "scalar.locale": {
         title: "Separator style",
         widget: ChartSettingSelect,
@@ -347,6 +482,18 @@ const SETTINGS = {
         title: "Multiply by a number",
         widget: ChartSettingInputNumeric
     },
+    "progress.goal": {
+        section: "Display",
+        title: "Goal",
+        widget: ChartSettingInputNumeric,
+        default: 0
+    },
+    "progress.color": {
+        section: "Display",
+        title: "Color",
+        widget: ChartSettingColorPicker,
+        default: normal.green
+    },
     "table.pivot": {
         title: "Pivot the table",
         widget: ChartSettingToggle,
@@ -481,10 +628,12 @@ const SETTINGS_PREFIXES_BY_CHART_TYPE = {
     line: ["graph.", "line."],
     area: ["graph.", "line.", "stackable."],
     bar: ["graph.", "stackable."],
+    scatter: ["graph.", "scatter."],
     pie: ["pie."],
     scalar: ["scalar."],
     table: ["table."],
-    map: ["map."]
+    map: ["map."],
+    progress: ["progress."],
 }
 
 // alias legacy map types
@@ -545,23 +694,25 @@ export function getSettings(series) {
 function getSettingWidget(id, vizSettings, series, onChangeSettings) {
     const settingDef = SETTINGS[id];
     const value = vizSettings[id];
+    const onChange = (value) => {
+        const newSettings = { [id]: value };
+        for (const id of (settingDef.writeDependencies || [])) {
+            newSettings[id] = vizSettings[id];
+        }
+        onChangeSettings(newSettings)
+    }
     return {
         ...settingDef,
         id: id,
         value: value,
+        title: settingDef.getTitle ? settingDef.getTitle(series, vizSettings) : settingDef.title,
         hidden: settingDef.getHidden ? settingDef.getHidden(series, vizSettings) : false,
         disabled: settingDef.getDisabled ? settingDef.getDisabled(series, vizSettings) : false,
         props: {
             ...(settingDef.props ? settingDef.props : {}),
-            ...(settingDef.getProps ? settingDef.getProps(series, vizSettings) : {})
+            ...(settingDef.getProps ? settingDef.getProps(series, vizSettings, onChange) : {})
         },
-        onChange: (value) => {
-            const newSettings = { [id]: value };
-            for (const id of (settingDef.writeDependencies || [])) {
-                newSettings[id] = vizSettings[id];
-            }
-            onChangeSettings(newSettings)
-        }
+        onChange
     };
 }
 
@@ -569,5 +720,5 @@ export function getSettingsWidgets(series, onChangeSettings) {
     const vizSettings = getSettings(series);
     return getSettingIdsForSeries(series).map(id =>
         getSettingWidget(id, vizSettings, series, onChangeSettings)
-    );
+    ).filter(widget => widget.widget && !widget.hidden);
 }
diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js
index 62c66d8b19aa240c333a60d427f225ccc38b2318..506c04473332d0861a9b476121d86b44a8c0d82e 100644
--- a/frontend/src/metabase/query_builder/actions.js
+++ b/frontend/src/metabase/query_builder/actions.js
@@ -790,14 +790,16 @@ export const queryCompleted = createThunkAction(QUERY_COMPLETED, (card, queryRes
         let cardDisplay = card.display;
 
         // try a little logic to pick a smart display for the data
-        if (card.display !== "scalar" &&
+        // TODO: less hard-coded rules for picking chart type
+        const isScalarVisualization = card.display === "scalar" || card.display === "progress";
+        if (!isScalarVisualization &&
                 queryResult.data.rows &&
                 queryResult.data.rows.length === 1 &&
                 queryResult.data.columns.length === 1) {
             // if we have a 1x1 data result then this should always be viewed as a scalar
             cardDisplay = "scalar";
 
-        } else if (card.display === "scalar" &&
+        } else if (isScalarVisualization &&
                     queryResult.data.rows &&
                     (queryResult.data.rows.length > 1 || queryResult.data.columns.length > 1)) {
             // any time we were a scalar and now have more than 1x1 data switch to table view
diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
index bcbce84455132a9ca485a15694d395a51662dcf0..45cea1a8f1eb6c51de397b8b36bb77ff1146dabe 100644
--- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
+++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
@@ -1,4 +1,5 @@
 import React, { Component, PropTypes } from "react";
+import ReactDOM from "react-dom";
 import { connect } from "react-redux";
 import cx from "classnames";
 import _ from "underscore";
@@ -150,6 +151,13 @@ export default class QueryBuilder extends Component {
         }
     }
 
+    componentDidUpdate() {
+        let viz = ReactDOM.findDOMNode(this.refs.viz);
+        if (viz) {
+            viz.style.opacity = 1.0;
+        }
+    }
+
     componentWillUnmount() {
         window.removeEventListener('resize', this.handleResize);
     }
@@ -158,6 +166,10 @@ export default class QueryBuilder extends Component {
     // Debounce the function to improve resizing performance.
     handleResize(e) {
         this.forceUpdateDebounced();
+        let viz = ReactDOM.findDOMNode(this.refs.viz);
+        if (viz) {
+            viz.style.opacity = 0.2;
+        }
     }
 
     render() {
@@ -196,7 +208,7 @@ export default class QueryBuilder extends Component {
                         }
                     </div>
 
-                    <div id="react_qb_viz" className="flex z1">
+                    <div ref="viz" id="react_qb_viz" className="flex z1" style={{ "transition": "opacity 0.25s ease-in-out" }}>
                         <QueryVisualization {...this.props}/>
                     </div>
                 </div>
diff --git a/frontend/src/metabase/visualizations/PieChart.jsx b/frontend/src/metabase/visualizations/PieChart.jsx
index e22d243dedd59cb25d4d735aec420b7a3f3d977b..7bb16fb948334c2bd86a86df2010138b4a774349 100644
--- a/frontend/src/metabase/visualizations/PieChart.jsx
+++ b/frontend/src/metabase/visualizations/PieChart.jsx
@@ -24,6 +24,8 @@ const PAD_ANGLE = (Math.PI / 180) * 1; // 1 degree in radians
 const SLICE_THRESHOLD = 1 / 360; // 1 degree in percentage
 const OTHER_SLICE_MIN_PERCENTAGE = 0.003;
 
+const PERCENT_REGEX = /percent/i;
+
 export default class PieChart extends Component {
     static displayName = "Pie";
     static identifier = "pie";
@@ -62,10 +64,13 @@ export default class PieChart extends Component {
         const formatMetric    =    (metric, jsx = true) => formatValue(metric, { column: cols[metricIndex], jsx, majorWidth: 0 })
         const formatPercent   =               (percent) => (100 * percent).toFixed(2) + "%"
 
+        const showPercentInTooltip = !PERCENT_REGEX.test(cols[metricIndex].name) && !PERCENT_REGEX.test(cols[metricIndex].display_name);
+
         let total = rows.reduce((sum, row) => sum + row[metricIndex], 0);
 
         // use standard colors for up to 5 values otherwise use color harmony to help differentiate slices
         let sliceColors = Object.values(rows.length > 5 ? colors.harmony : colors.normal);
+        let sliceThreshold = typeof settings["pie.slice_threshold"] === "number" ? settings["pie.slice_threshold"] / 100 : SLICE_THRESHOLD;
 
         let [slices, others] = _.chain(rows)
             .map((row, index) => ({
@@ -74,7 +79,7 @@ export default class PieChart extends Component {
                 percentage: row[metricIndex] / total,
                 color: sliceColors[index % sliceColors.length]
             }))
-            .partition((d) => d.percentage > SLICE_THRESHOLD)
+            .partition((d) => d.percentage > sliceThreshold)
             .value();
 
         let otherTotal = others.reduce((acc, o) => acc + o.value, 0);
@@ -119,8 +124,7 @@ export default class PieChart extends Component {
             : [
                 { key: getFriendlyName(cols[dimensionIndex]), value: formatDimension(slices[index].key) },
                 { key: getFriendlyName(cols[metricIndex]), value: formatMetric(slices[index].value) },
-                { key: "Percentage", value: formatPercent(slices[index].percentage) }
-            ]
+            ].concat(showPercentInTooltip ? [{ key: "Percentage", value: formatPercent(slices[index].percentage) }] : [])
         });
 
         let value, title;
diff --git a/frontend/src/metabase/visualizations/Progress.jsx b/frontend/src/metabase/visualizations/Progress.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..9c6a7e766a6193dbd4b5634ab9c571b2bddf6082
--- /dev/null
+++ b/frontend/src/metabase/visualizations/Progress.jsx
@@ -0,0 +1,156 @@
+import React, { Component, PropTypes } from "react";
+import ReactDOM from "react-dom";
+
+import { formatValue } from "metabase/lib/formatting";
+import Icon from "metabase/components/Icon.jsx";
+import IconBorder from "metabase/components/IconBorder.jsx";
+
+import Color from "color";
+
+const BORDER_RADIUS = 5;
+
+export default class Progress extends Component {
+    static displayName = "Progress";
+    static identifier = "progress";
+    static iconName = "number";
+
+    static minSize = { width: 3, height: 3 };
+
+    static isSensible(cols, rows) {
+        return rows.length === 1 && cols.length === 1;
+    }
+
+    static checkRenderable(cols, rows) {
+    }
+
+    componentDidMount() {
+        this.componentDidUpdate();
+    }
+
+    componentDidUpdate() {
+        const pointer = ReactDOM.findDOMNode(this.refs.pointer);
+        const label = ReactDOM.findDOMNode(this.refs.label);
+        const container = ReactDOM.findDOMNode(this.refs.container);
+
+        if (this.props.gridSize && this.props.gridSize.height < 4) {
+            pointer.parentNode.style.display = "none";
+            label.parentNode.style.display = "none";
+            // no need to do the rest of the repositioning
+            return;
+        } else {
+            pointer.parentNode.style.display = null;
+            label.parentNode.style.display = null;
+        }
+
+        // reset the pointer transform for these computations
+        pointer.style.transform = null;
+
+        // position the label
+        const containerWidth = container.offsetWidth;
+        const labelWidth = label.offsetWidth;
+        const pointerWidth = pointer.offsetWidth;
+        const pointerCenter = pointer.offsetLeft + pointerWidth / 2;
+        const minOffset = (-pointerWidth / 2) + BORDER_RADIUS
+        if (pointerCenter - labelWidth / 2 < minOffset) {
+            label.style.left = minOffset + "px";
+            label.style.right = null
+        } else if (pointerCenter + labelWidth / 2 > containerWidth - minOffset) {
+            label.style.left = null
+            label.style.right = minOffset + "px";
+        } else {
+            label.style.left = (pointerCenter - labelWidth / 2) + "px";
+            label.style.right = null;
+        }
+
+        // shift pointer at ends inward to line up with border radius
+        if (pointerCenter < BORDER_RADIUS) {
+            pointer.style.transform = "translate(" + BORDER_RADIUS + "px,0)";
+        } else if (pointerCenter > containerWidth - 5) {
+            pointer.style.transform = "translate(-" + BORDER_RADIUS + "px,0)";
+        }
+    }
+
+    render() {
+        const { series: [{ data: { rows } }], settings } = this.props;
+        const value = rows[0][0];
+        const goal = settings["progress.goal"] || 0;
+
+        const mainColor = settings["progress.color"];
+        const lightColor = Color(mainColor).lighten(0.25).rgbString();
+        const darkColor = Color(mainColor).darken(0.30).rgbString();
+
+        const progressColor = mainColor;
+        const restColor = value > goal ? darkColor : lightColor;
+        const arrowColor = value > goal ? darkColor : mainColor;
+
+        const barPercent = Math.max(0, value < goal ? value / goal : goal / value);
+        const arrowPercent = Math.max(0, value < goal ? value / goal : 1);
+
+        let barMessage;
+        if (value === goal) {
+            barMessage = "Goal met";
+        } else if (value > goal) {
+            barMessage = "Goal exceeded";
+        }
+
+        return (
+            <div className="full-height flex layout-centered">
+                <div className="flex-full full-height flex flex-column justify-center" style={{ padding: 10, paddingTop: 0 }}>
+                    <div
+                        ref="container"
+                        className="relative text-bold text-grey-4"
+                        style={{ height: 20 }}
+                    >
+                        <div
+                            ref="label"
+                            style={{ position: "absolute" }}
+                        >
+                            {formatValue(value, { comma: true })}
+                        </div>
+                    </div>
+                    <div className="relative" style={{ height: 10, marginBottom: 5 }}>
+                        <div
+                            ref="pointer"
+                            style={{
+                                width: 0,
+                                height: 0,
+                                position: "absolute",
+                                left: (arrowPercent * 100) + "%",
+                                marginLeft: -10,
+                                borderLeft: "10px solid transparent",
+                                borderRight: "10px solid transparent",
+                                borderTop: "10px solid " + arrowColor
+                            }}
+                        />
+                    </div>
+                    <div className="relative" style={{
+                        backgroundColor: restColor,
+                        borderRadius: BORDER_RADIUS,
+                        height: "25%",
+                        maxHeight: 65,
+                        overflow: "hidden"
+                    }}>
+                        <div style={{
+                                backgroundColor: progressColor,
+                                width: (barPercent * 100) + "%",
+                                height: "100%"
+                            }}
+                        />
+                        { barMessage &&
+                            <div className="flex align-center absolute spread text-white text-bold px2">
+                                <IconBorder borderWidth={2}>
+                                    <Icon name="check" size={14} />
+                                </IconBorder>
+                                <div className="pl2">{barMessage}</div>
+                            </div>
+                        }
+                    </div>
+                    <div className="mt1">
+                        <span className="float-left">0</span>
+                        <span className="float-right">Goal {formatValue(goal, { comma: true })}</span>
+                    </div>
+                </div>
+            </div>
+        );
+    }
+}
diff --git a/frontend/src/metabase/visualizations/ScatterPlot.jsx b/frontend/src/metabase/visualizations/ScatterPlot.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..76de5e1c6f2bb980b84979f500160a9f8274a222
--- /dev/null
+++ b/frontend/src/metabase/visualizations/ScatterPlot.jsx
@@ -0,0 +1,10 @@
+import React, { Component, PropTypes } from "react";
+
+import LineAreaBarChart from "./components/LineAreaBarChart.jsx";
+
+export default class ScatterPlot extends LineAreaBarChart {
+    static displayName = "Scatter";
+    static identifier = "scatter";
+    static iconName = "line";
+    static noun = "scatter plot";
+}
diff --git a/frontend/src/metabase/visualizations/components/CardRenderer.jsx b/frontend/src/metabase/visualizations/components/CardRenderer.jsx
index c65d50a7c890fec736400a0ba27a3091a0ae650d..4a84fd0521f69fa716b64bd186ff1575c6c641cc 100644
--- a/frontend/src/metabase/visualizations/components/CardRenderer.jsx
+++ b/frontend/src/metabase/visualizations/components/CardRenderer.jsx
@@ -14,8 +14,8 @@ import cx from "classnames";
 export default class CardRenderer extends Component {
     static propTypes = {
         series: PropTypes.array.isRequired,
-        width: PropTypes.number.isRequired,
-        height: PropTypes.number.isRequired,
+        width: PropTypes.number,
+        height: PropTypes.number,
         renderer: PropTypes.func.isRequired,
         onRenderError: PropTypes.func.isRequired,
         className: PropTypes.string
diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.jsx b/frontend/src/metabase/visualizations/components/ChartSettings.jsx
index a54020fcbfbd8dfeea9c5090e6d78aaf1744f345..10374c314c2bcb995a6a51cf1928b994bea71879 100644
--- a/frontend/src/metabase/visualizations/components/ChartSettings.jsx
+++ b/frontend/src/metabase/visualizations/components/ChartSettings.jsx
@@ -27,7 +27,7 @@ const ChartSettingsTabs = ({ tabs, selectTab, activeTab}) =>
 const Widget = ({ title, hidden, disabled, widget, value, onChange, props }) => {
     const W = widget;
     return (
-        <div className={cx("mb3", { hide: hidden, disable: disabled })}>
+        <div className={cx("mb2", { hide: hidden, disable: disabled })}>
             { title && <h4 className="mb1">{title}</h4> }
             { W && <W value={value} onChange={onChange} {...props}/> }
         </div>
diff --git a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx
index 45203999085f2c950d3457ceaa49bfc869916385..09d9aac7eb1af4626a128d6c962038fe66181990 100644
--- a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx
+++ b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx
@@ -18,6 +18,12 @@ export default class ChartTooltip extends Component {
     static defaultProps = {
     };
 
+    componentWillReceiveProps({ hovered }) {
+        if (hovered && !Array.isArray(hovered.data)) {
+            console.warn("hovered.data should be an array of { key, value, col }", hovered.data);
+        }
+    }
+
     render() {
         const { series, hovered } = this.props;
         if (!(hovered && hovered.data && ((hovered.element && document.contains(hovered.element)) || hovered.event))) {
@@ -33,10 +39,16 @@ export default class ChartTooltip extends Component {
                 <table className="py1 px2">
                     <tbody>
                         { Array.isArray(hovered.data)  ?
-                            hovered.data.map(({ key, value }, index) =>
+                            hovered.data.map(({ key, value, col }, index) =>
                                 <tr key={index}>
                                     <td className="text-light text-right">{key}:</td>
-                                    <td className="pl1 text-bold text-left">{value}</td>
+                                    <td className="pl1 text-bold text-left">
+                                        { col ?
+                                            formatValue(value, { column: col, jsx: true, majorWidth: 0 })
+                                        :
+                                            value
+                                        }
+                                    </td>
                                 </tr>
                             )
                         :
diff --git a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx
index 1664d4664fc61c196292d47979b3cf3b2cb7c499..5e9e6fe99c5c555d1c22f7bbceee837bba2ec799 100644
--- a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx
+++ b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx
@@ -99,12 +99,14 @@ export default class LineAreaBarChart extends Component {
                 _.findIndex(cols, (col) => col.name === metricName)
             );
 
+            const bubbleIndex = settings["scatter.bubble"] && _.findIndex(cols, (col) => col.name === settings["scatter.bubble"]);
+            const extraIndexes = bubbleIndex && bubbleIndex >= 0 ? [bubbleIndex] : [];
+
             if (dimensions.length > 1) {
                 const dataset = crossfilter(rows);
                 const [dimensionIndex, seriesIndex] = dimensionIndexes;
-                const rowIndexes = [dimensionIndex].concat(metricIndexes);
+                const rowIndexes = [dimensionIndex].concat(metricIndexes, extraIndexes);
                 const seriesGroup = dataset.dimension(d => d[seriesIndex]).group()
-
                 nextState.series = seriesGroup.reduce(
                     (p, v) => p.concat([rowIndexes.map(i => v[i])]),
                     (p, v) => null, () => []
@@ -124,6 +126,7 @@ export default class LineAreaBarChart extends Component {
 
                 nextState.series = metricIndexes.map(metricIndex => {
                     const col = cols[metricIndex];
+                    const rowIndexes = [dimensionIndex].concat(metricIndex, extraIndexes);
                     return {
                         card: {
                             ...s.card,
@@ -131,8 +134,10 @@ export default class LineAreaBarChart extends Component {
                             name: getFriendlyName(col)
                         },
                         data: {
-                            rows: rows.map(row => [row[dimensionIndex], row[metricIndex]]),
-                            cols: [cols[dimensionIndex], s.data.cols[metricIndex]]
+                            rows: rows.map(row =>
+                                rowIndexes.map(i => row[i])
+                            ),
+                            cols: rowIndexes.map(i => s.data.cols[i])
                         }
                     };
                 });
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..631b8506451958f3f902ab703e9374d4aaa307f8
--- /dev/null
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker.jsx
@@ -0,0 +1,47 @@
+import React, { Component, PropTypes } from "react";
+
+import { normal } from 'metabase/lib/colors'
+const DEFAULT_COLOR_HARMONY = Object.values(normal);
+
+import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx";
+
+export default class ChartSettingColorPicker extends Component {
+    render() {
+        const { value, onChange, title } = this.props;
+        return (
+            <div className="flex align-center">
+                <PopoverWithTrigger
+                    ref="colorPopover"
+                    hasArrow={false}
+                    tetherOptions={{
+                        attachment: 'middle left',
+                        targetAttachment: 'middle right',
+                        targetOffset: '0 0',
+                        constraints: [{ to: 'window', attachment: 'together', pin: ['left', 'right']}]
+                    }}
+                    triggerElement={
+                        <span className="ml1 mr2 bordered inline-block cursor-pointer" style={{ padding: 4, borderRadius: 3 }}>
+                            <div style={{ width: 15, height: 15, backgroundColor: value }} />
+                        </span>
+                    }
+                >
+                    <ol className="p1">
+                        {DEFAULT_COLOR_HARMONY.map((color, colorIndex) =>
+                            <li
+                                key={colorIndex}
+                                className="CardSettings-colorBlock"
+                                style={{ backgroundColor: color }}
+                                onClick={() => {
+                                    onChange(color);
+                                    this.refs.colorPopover.close();
+                                }}
+                            ></li>
+                        )}
+                    </ol>
+                </PopoverWithTrigger>
+
+                <span className="text-bold">{title}</span>
+            </div>
+        );
+    }
+}
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx
index f5fd68c742d05f470afced27bb26c7936b50753a..9ca6cae19b3b9397fc44162c89f360e0990e87a2 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx
@@ -1,9 +1,6 @@
 import React, { Component, PropTypes } from "react";
 
-import { normal } from 'metabase/lib/colors'
-const DEFAULT_COLOR_HARMONY = Object.values(normal);
-
-import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx";
+import ChartSettingColorPicker from "./ChartSettingColorPicker.jsx";
 
 export default class ChartSettingColorsPicker extends Component {
     render() {
@@ -11,39 +8,12 @@ export default class ChartSettingColorsPicker extends Component {
         return (
             <div>
                 { seriesTitles.map((title, index) =>
-                    <div key={index} className="flex align-center">
-                        <PopoverWithTrigger
-                            ref="colorPopover"
-                            hasArrow={false}
-                            tetherOptions={{
-                                attachment: 'middle left',
-                                targetAttachment: 'middle right',
-                                targetOffset: '0 0',
-                                constraints: [{ to: 'window', attachment: 'together', pin: ['left', 'right']}]
-                            }}
-                            triggerElement={
-                                <span className="ml1 mr2 bordered inline-block cursor-pointer" style={{ padding: 4, borderRadius: 3 }}>
-                                    <div style={{ width: 15, height: 15, backgroundColor: value[index] }} />
-                                </span>
-                            }
-                        >
-                            <ol className="p1">
-                                {DEFAULT_COLOR_HARMONY.map((color, colorIndex) =>
-                                    <li
-                                        key={colorIndex}
-                                        className="CardSettings-colorBlock"
-                                        style={{ backgroundColor: color }}
-                                        onClick={() => {
-                                            onChange([...value.slice(0, index), color, ...value.slice(index + 1)]);
-                                            this.refs.colorPopover.close();
-                                        }}
-                                    ></li>
-                                )}
-                            </ol>
-                        </PopoverWithTrigger>
-
-                        <span className="text-bold">{title}</span>
-                    </div>
+                    <ChartSettingColorPicker
+                        key={index}
+                        value={value[index]}
+                        onChange={(color) => onChange([...value.slice(0, index), color, ...value.slice(index + 1)])}
+                        title={title}
+                    />
                 )}
             </div>
         );
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..2c7e9896d4e543749099ae6338367e71e13db6a1
--- /dev/null
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx
@@ -0,0 +1,29 @@
+import React, { Component, PropTypes } from "react";
+
+import Icon from "metabase/components/Icon";
+import cx from "classnames";
+
+import ChartSettingSelect from "./ChartSettingSelect.jsx";
+
+const ChartSettingFieldPicker = ({ value, options, onChange, onRemove }) =>
+    <div className="flex align-center">
+        <ChartSettingSelect
+            value={value}
+            options={options}
+            onChange={onChange}
+            placeholder="Select a field"
+            placeholderNoOptions="No valid fields"
+            isInitiallyOpen={value === undefined}
+        />
+        <Icon
+            name="close"
+            className={cx("ml1 text-grey-4 text-brand-hover cursor-pointer", {
+                "disabled hidden": !onRemove
+            })}
+            width={12} height={12}
+            onClick={onRemove}
+        />
+    </div>
+
+
+export default ChartSettingFieldPicker;
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx
index 45c9e18e99b815456373caab3d762f96a606019d..7e6ea5cc982dc8d9d68ae9788636d9212a71a23a 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx
@@ -1,39 +1,30 @@
 import React, { Component, PropTypes } from "react";
 
-import Icon from "metabase/components/Icon";
-import cx from "classnames";
+import ChartSettingFieldPicker from "./ChartSettingFieldPicker.jsx";
 
-import ChartSettingSelect from "./ChartSettingSelect.jsx";
-
-const ChartSettingFieldsPicker = ({ value = [], onChange, options, addAnother }) =>
+const ChartSettingFieldsPicker = ({ value = [], options, onChange, addAnother }) =>
     <div>
         { Array.isArray(value) ? value.map((v, index) =>
-            <div key={index} className="flex align-center">
-                <ChartSettingSelect
-                    value={v}
-                    options={options}
-                    onChange={(v) => {
-                        let newValue = [...value];
-                        // this swaps the position of the existing value
-                        let existingIndex = value.indexOf(v);
-                        if (existingIndex >= 0) {
-                            newValue.splice(existingIndex, 1, value[index]);
-                        }
-                        // replace with the new value
-                        newValue.splice(index, 1, v);
-                        onChange(newValue);
-                    }}
-                    isInitiallyOpen={v === undefined}
-                />
-                <Icon
-                    name="close"
-                    className={cx("ml1 text-grey-4 text-brand-hover cursor-pointer", {
-                        "disabled hidden": value.filter(v => v != null).length < 2
-                    })}
-                    width={12} height={12}
-                    onClick={() => onChange([...value.slice(0, index), ...value.slice(index + 1)])}
-                />
-            </div>
+            <ChartSettingFieldPicker
+                key={index}
+                value={v}
+                options={options}
+                onChange={(v) => {
+                    let newValue = [...value];
+                    // this swaps the position of the existing value
+                    let existingIndex = value.indexOf(v);
+                    if (existingIndex >= 0) {
+                        newValue.splice(existingIndex, 1, value[index]);
+                    }
+                    // replace with the new value
+                    newValue.splice(index, 1, v);
+                    onChange(newValue);
+                }}
+                onRemove={value.filter(v => v != null).length > 1 || (value.length > 1 && v == null) ?
+                    () => onChange([...value.slice(0, index), ...value.slice(index + 1)]) :
+                    null
+                }
+            />
         ) : <span className="text-error">error</span>}
         { addAnother &&
             <div className="mt1">
diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingSelect.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingSelect.jsx
index c2828825ffd6e941c5d70a8f37ccc0cae1d914b3..b8a2875d4fcf9d591e1caf2860f7cb5fad5d1378 100644
--- a/frontend/src/metabase/visualizations/components/settings/ChartSettingSelect.jsx
+++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingSelect.jsx
@@ -3,15 +3,17 @@ import React, { Component, PropTypes } from "react";
 import Select from "metabase/components/Select.jsx";
 
 import _ from "underscore";
+import cx from "classnames";
 
-const ChartSettingSelect = ({ value, onChange, options = [], isInitiallyOpen }) =>
+const ChartSettingSelect = ({ value, onChange, options = [], isInitiallyOpen, className, placeholder, placeholderNoOptions }) =>
     <Select
-        className="block flex-full"
+        className={cx(className, "block flex-full", { disabled: options.length === 0 || (options.length === 1 && options[0].value === value) })}
         value={_.findWhere(options, { value })}
         options={options}
         optionNameFn={(o) => o.name}
         optionValueFn={(o) => o.value}
         onChange={onChange}
+        placeholder={options.length === 0 ? placeholderNoOptions : placeholder}
         isInitiallyOpen={isInitiallyOpen}
     />
 
diff --git a/frontend/src/metabase/visualizations/index.js b/frontend/src/metabase/visualizations/index.js
index f0912c8a1c6eeea9c661a3592c451dad0609b8b5..e0fa06a869645327747f0e89f9c60c52702a382f 100644
--- a/frontend/src/metabase/visualizations/index.js
+++ b/frontend/src/metabase/visualizations/index.js
@@ -1,11 +1,13 @@
 
 import Scalar     from "./Scalar.jsx";
+import Progress   from "./Progress.jsx";
 import Table      from "./Table.jsx";
 import LineChart  from "./LineChart.jsx";
 import BarChart   from "./BarChart.jsx";
 import PieChart   from "./PieChart.jsx";
 import AreaChart  from "./AreaChart.jsx";
 import MapViz     from "./Map.jsx";
+import ScatterPlot from "./ScatterPlot.jsx";
 
 const visualizations = new Map();
 const aliases = new Map();
@@ -28,14 +30,15 @@ export function registerVisualization(visualization) {
 }
 
 registerVisualization(Scalar);
+registerVisualization(Progress);
 registerVisualization(Table);
 registerVisualization(LineChart);
 registerVisualization(BarChart);
-registerVisualization(PieChart);
 registerVisualization(AreaChart);
+registerVisualization(ScatterPlot);
+registerVisualization(PieChart);
 registerVisualization(MapViz);
 
-
 import { enableVisualizationEasterEgg } from "./lib/utils";
 import XKCDChart from "./XKCDChart.jsx";
 import LineAreaBarChart from "./components/LineAreaBarChart.jsx";
diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
index d36b1a32cfeea1cb885a39b41bc2521f58869918..49c40f3565a9980716bcbd024d957e86d1f42d87 100644
--- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
+++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js
@@ -2,6 +2,7 @@ import crossfilter from "crossfilter";
 import d3 from "d3";
 import dc from "dc";
 import moment from "moment";
+import _ from "underscore";
 
 import {
     getAvailableCanvasWidth,
@@ -13,11 +14,14 @@ import {
 
 import {
     minTimeseriesUnit,
-    dimensionIsTimeseries,
     computeTimeseriesDataInverval,
     computeTimeseriesTicksInterval
 } from "./timeseries";
 
+import {
+    computeNumericDataInverval
+} from "./numeric";
+
 import { determineSeriesIndexFromElement } from "./tooltip";
 
 import { colorShades } from "./utils";
@@ -51,43 +55,26 @@ function getDcjsChartType(cardType) {
     switch (cardType) {
         case "line": return "lineChart";
         case "area": return "lineChart";
-        case "bar":  return "barChart";
+        case "bar":     return "barChart";
+        case "scatter": return "bubbleChart";
         default:     return "barChart";
     }
 }
 
-function initializeChart(card, element, chartType = getDcjsChartType(card.display)) {
-    // create the chart
-    let chart = dc[chartType](element);
-
-    // set width and height
-    chart = applyChartBoundary(chart, element);
-
-    // disable animations
-    chart.transitionDuration(0);
-
-    return chart;
-}
-
 function applyChartBoundary(chart, element) {
     return chart
         .width(getAvailableCanvasWidth(element))
         .height(getAvailableCanvasHeight(element));
 }
 
-function applyChartTimeseriesXAxis(chart, settings, series, xValues) {
+function applyChartTimeseriesXAxis(chart, settings, series, xValues, xDomain, xInterval) {
     // setup an x-axis where the dimension is a timeseries
     let dimensionColumn = series[0].data.cols[0];
 
-    let unit = minTimeseriesUnit(series.map(s => s.data.cols[0].unit));
-
     // compute the data interval
-    let dataInterval = computeTimeseriesDataInverval(xValues, unit);
+    let dataInterval = xInterval;
     let tickInterval = dataInterval;
 
-    // compute the domain
-    let xDomain = d3.extent(xValues);
-
     if (settings["graph.x_axis.labels_enabled"]) {
         chart.xAxisLabel(settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn));
     }
@@ -99,21 +86,21 @@ function applyChartTimeseriesXAxis(chart, settings, series, xValues) {
         }
 
         chart.xAxis().tickFormat(timestamp => {
-            // these dates are in the browser's timezone, change to UTC
+            // HACK: these dates are in the browser's timezone, change to UTC
             let timestampUTC = moment(timestamp).format().replace(/[+-]\d+:\d+$/, "Z");
             return formatValue(timestampUTC, { column: dimensionColumn })
         });
 
         // Compute a sane interval to display based on the data granularity, domain, and chart width
-        tickInterval = computeTimeseriesTicksInterval(xValues, unit, chart.width(), MIN_PIXELS_PER_TICK.x);
+        tickInterval = computeTimeseriesTicksInterval(xDomain, dataInterval, chart.width(), MIN_PIXELS_PER_TICK.x, );
         chart.xAxis().ticks(d3.time[tickInterval.interval], tickInterval.count);
     } else {
         chart.xAxis().ticks(0);
     }
 
     // pad the domain slightly to prevent clipping
-    xDomain[0] = moment(xDomain[0]).subtract(dataInterval.count * 0.75, dataInterval.interval);
-    xDomain[1] = moment(xDomain[1]).add(dataInterval.count * 0.75, dataInterval.interval);
+    xDomain[0] = moment(xDomain[0]).subtract(dataInterval.count * 0.75, dataInterval.interval).toDate();
+    xDomain[1] = moment(xDomain[1]).add(dataInterval.count * 0.75, dataInterval.interval).toDate();
 
     // set the x scale
     chart.x(d3.time.scale.utc().domain(xDomain));//.nice(d3.time[dataInterval.interval]));
@@ -122,6 +109,44 @@ function applyChartTimeseriesXAxis(chart, settings, series, xValues) {
     chart.xUnits((start, stop) => Math.ceil(1 + moment(stop).diff(start, dataInterval.interval) / dataInterval.count));
 }
 
+function applyChartQuantitativeXAxis(chart, settings, series, xValues, xDomain, xInterval) {
+    const dimensionColumn = series[0].data.cols[0];
+
+    if (settings["graph.x_axis.labels_enabled"]) {
+        chart.xAxisLabel(settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn));
+    }
+    if (settings["graph.x_axis.axis_enabled"]) {
+        chart.renderVerticalGridLines(settings["graph.x_axis.gridLine_enabled"]);
+        adjustTicksIfNeeded(chart.xAxis(), chart.width(), MIN_PIXELS_PER_TICK.x);
+
+        chart.xAxis().tickFormat(d => formatValue(d, { column: dimensionColumn }));
+    } else {
+        chart.xAxis().ticks(0);
+        chart.xAxis().tickFormat('');
+    }
+
+    let scale;
+    if (settings["graph.x_axis.scale"] === "pow") {
+        scale = d3.scale.pow().exponent(0.5);
+    } else if (settings["graph.x_axis.scale"] === "log") {
+        scale = d3.scale.log().base(Math.E);
+        if (!((xDomain[0] < 0 && xDomain[1] < 0) || (xDomain[0] > 0 && xDomain[1] > 0))) {
+            throw "X-axis must not cross 0 when using log scale.";
+        }
+    } else {
+        scale = d3.scale.linear();
+    }
+
+    // pad the domain slightly to prevent clipping
+    xDomain = [
+        xDomain[0] - xInterval * 0.75,
+        xDomain[1] + xInterval * 0.75
+    ];
+
+    chart.x(scale.domain(xDomain))
+        .xUnits(dc.units.fp.precision(xInterval));
+}
+
 function applyChartOrdinalXAxis(chart, settings, series, xValues) {
     const dimensionColumn = series[0].data.cols[0];
 
@@ -154,51 +179,99 @@ function applyChartOrdinalXAxis(chart, settings, series, xValues) {
         .xUnits(dc.units.ordinal);
 }
 
-function applyChartYAxis(chart, settings, series, yAxisSplit) {
-
-    if (settings["graph.y_axis.labels_enabled"]) {
+function applyChartYAxis(chart, settings, series, yExtent, axisName) {
+    let axis;
+    if (axisName === "left") {
+        axis = {
+            scale:   (...args) => chart.y(...args),
+            axis:    (...args) => chart.yAxis(...args),
+            label:   (...args) => chart.yAxisLabel(...args),
+            setting: (name) => settings["graph.y_axis." + name]
+        };
+    } else if (axisName === "right") {
+        axis = {
+            scale:   (...args) => chart.rightY(...args),
+            axis:    (...args) => chart.rightYAxis(...args),
+            label:   (...args) => chart.rightYAxisLabel(...args),
+            setting: (name) => settings["graph.y_axis." + name] // TODO: right axis settings
+        };
+    }
+
+    if (axis.setting("labels_enabled")) {
         // left
-        if (settings["graph.y_axis.title_text"]) {
-            chart.yAxisLabel(settings["graph.y_axis.title_text"]);
-        } else if (yAxisSplit[0].length === 1) {
-            chart.yAxisLabel(getFriendlyName(series[yAxisSplit[0][0]].data.cols[1]));
-        }
-        // right
-        if (yAxisSplit.length > 1 && yAxisSplit[1].length === 1) {
-            chart.rightYAxisLabel(getFriendlyName(series[yAxisSplit[1][0]].data.cols[1]));
+        if (axis.setting("title_text")) {
+            axis.label(axis.setting("title_text"));
+        } else {
+            // only use the column name if all in the series are the same
+            const labels = _.uniq(series.map(s => getFriendlyName(s.data.cols[1])));
+            if (labels.length === 1) {
+                axis.label(labels[0]);
+            }
         }
     }
 
-    if (settings["graph.y_axis.axis_enabled"]) {
+    if (axis.setting("axis_enabled")) {
         chart.renderHorizontalGridLines(true);
+        adjustTicksIfNeeded(axis.axis(), chart.height(), MIN_PIXELS_PER_TICK.y);
+    } else {
+        axis.axis().ticks(0);
+    }
 
-        adjustTicksIfNeeded(chart.yAxis(), chart.height(), MIN_PIXELS_PER_TICK.y);
-        if (yAxisSplit.length > 1 && chart.rightYAxis) {
-            adjustTicksIfNeeded(chart.rightYAxis(), chart.height(), MIN_PIXELS_PER_TICK.y);
-        }
+    let scale;
+    if (axis.setting("scale") === "pow") {
+        scale = d3.scale.pow().exponent(0.5);
+    } else if (axis.setting("scale") === "log") {
+        scale = d3.scale.log().base(Math.E);
+        // axis.axis().tickFormat((d) => scale.tickFormat(4,d3.format(",d"))(d));
     } else {
-        chart.yAxis().ticks(0);
-        if (chart.rightYAxis) {
-            chart.rightYAxis().ticks(0);
-        }
+        scale = d3.scale.linear();
     }
 
-    if (settings["graph.y_axis.auto_range"]) {
-        chart.elasticY(true);
+    if (axis.setting("auto_range")) {
+        // elasticY not compatible with log scale
+        if (axis.setting("scale") !== "log") {
+            // TODO: right axis?
+            chart.elasticY(true);
+        } else {
+            if (!((yExtent[0] < 0 && yExtent[1] < 0) || (yExtent[0] > 0 && yExtent[1] > 0))) {
+                throw "Y-axis must not cross 0 when using log scale.";
+            }
+            scale.domain(yExtent);
+        }
+        axis.scale(scale);
     } else {
-        chart.y(d3.scale.linear().domain([settings["graph.y_axis.min"], settings["graph.y_axis.max"]]))
+        if (axis.setting("scale") === "log" && !(
+            (axis.setting("min") < 0 && axis.setting("max") < 0) ||
+            (axis.setting("min") > 0 && axis.setting("max") > 0)
+        )) {
+            throw "Y-axis must not cross 0 when using log scale.";
+        }
+        axis.scale(scale.domain([axis.setting("min"), axis.setting("max")]))
     }
 }
 
-function applyChartTooltips(chart, onHoverChange) {
+function applyChartTooltips(chart, series, onHoverChange) {
+    let [{ data: { cols } }] = series;
     chart.on("renderlet.tooltips", function(chart) {
-        chart.selectAll(".bar, .dot, .area, .line, g.pie-slice, g.features")
+        chart.selectAll(".bar, .dot, .area, .line, .bubble, g.pie-slice, g.features")
             .on("mousemove", function(d, i) {
+                let data;
+                if (Array.isArray(d.key)) { // scatter
+                    data = d.key.map((value, index) => (
+                        { key: getFriendlyName(cols[index]), value: value, col: cols[index] }
+                    ));
+                } else if (d.data) { // line, area, bar
+                    data = [
+                        { key: getFriendlyName(cols[0]), value: d.data.key, col: cols[0] },
+                        { key: getFriendlyName(cols[1]), value: d.data.value, col: cols[1] }
+                    ];
+                }
+
                 onHoverChange && onHoverChange({
                     index: determineSeriesIndexFromElement(this),
                     element: this,
                     d: d,
-                    data: d.data
+                    data: data && _.uniq(data, (d) => d.col)
                 });
             })
             .on("mouseleave", function() {
@@ -209,7 +282,7 @@ function applyChartTooltips(chart, onHoverChange) {
     });
 }
 
-function applyChartLineBarSettings(chart, settings, chartType, isLinear, isTimeseries) {
+function applyChartLineBarSettings(chart, settings, chartType) {
     // if the chart supports 'brushing' (brush-based range filter), disable this since it intercepts mouse hovers which means we can't see tooltips
     if (chart.brushOn) {
         chart.brushOn(false);
@@ -234,11 +307,11 @@ function applyChartLineBarSettings(chart, settings, chartType, isLinear, isTimes
     if (chart.barPadding) {
         chart
             .barPadding(BAR_PADDING_RATIO)
-            .centerBar(isLinear || isTimeseries);
+            .centerBar(settings["graph.x_axis.scale"] !== "ordinal");
     }
 }
 
-function lineAndBarOnRender(chart, settings) {
+function lineAndBarOnRender(chart, settings, onGoalHover, isSplitAxis) {
     // once chart has rendered and we can access the SVG, do customizations to axis labels / etc that you can't do through dc.js
 
     function removeClipPath() {
@@ -294,7 +367,7 @@ function lineAndBarOnRender(chart, settings) {
 
     function voronoiHover() {
         const parent = chart.svg().select("svg > g");
-        const dots = chart.svg().selectAll(".dc-tooltip .dot")[0];
+        const dots = chart.svg().selectAll(".sub .dc-tooltip .dot")[0];
 
         if (dots.length === 0 || dots.length > VORONOI_MAX_POINTS) {
             return;
@@ -403,6 +476,47 @@ function lineAndBarOnRender(chart, settings) {
             });
     }
 
+    function fixStackZIndex() {
+        // reverse the order of .stack-list and .dc-tooltip-list children so 0 points in stacked
+        // charts don't appear on top of non-zero points
+        for (const list of chart.selectAll(".stack-list, .dc-tooltip-list")[0]) {
+            for (const child of list.childNodes) {
+                list.insertBefore(list.firstChild, child);
+            }
+        }
+    }
+
+    function cleanupGoal() {
+        // remove dots
+        chart.selectAll(".goal .dot").remove();
+
+        // move to end of the parent node so it's on top
+        chart.selectAll(".goal").each(function() { this.parentNode.appendChild(this); });
+        chart.selectAll(".goal .line").attr({
+            "stroke": "rgba(157,160,164, 0.7)",
+            "stroke-dasharray": "5,5"
+        });
+
+        // add the label
+        let goalLine = chart.selectAll(".goal .line")[0][0];
+        if (goalLine) {
+            let { x, y, width } = goalLine.getBBox();
+            const labelOnRight = !isSplitAxis;
+            chart.selectAll(".goal .stack._0")
+                .append("text")
+                .text("Goal")
+                .attr({
+                    x: labelOnRight ? x + width : x,
+                    y: y - 5,
+                    "text-anchor": labelOnRight ? "end" : "start",
+                    "font-weight": "bold",
+                    fill: "rgb(157,160,164)",
+                })
+                .on("mouseenter", function() { onGoalHover(this); })
+                .on("mouseleave", function() { onGoalHover(null); })
+        }
+    }
+
     // run these first so the rest of the margin computations take it into account
     hideDisabledLabels();
     hideDisabledAxis();
@@ -431,34 +545,141 @@ function lineAndBarOnRender(chart, settings) {
         hideDisabledAxis();
         hideBadAxis();
         disableClickFiltering();
+        fixStackZIndex();
+        cleanupGoal();
     });
 
     chart.render();
 }
 
+function reduceGroup(group, key) {
+    return group.reduce(
+        (acc, d) => (acc == null && d[key] == null) ? null : (acc || 0) + (d[key] || 0),
+        (acc, d) => (acc == null && d[key] == null) ? null : (acc || 0) - (d[key] || 0),
+        () => null
+    );
+}
+
+function fillMissingValues(datas, xValues, fillValue, getKey = (v) => v) {
+    try {
+        return datas.map(rows => {
+            const fillValues = rows[0].slice(1).map(d => fillValue);
+
+            let map = new Map();
+            for (const row of rows) {
+                map.set(getKey(row[0]), row);
+            }
+            let newRows = xValues.map(value => {
+                const key = getKey(value);
+                const row = map.get(key);
+                if (row) {
+                    map.delete(key);
+                    return [value, ...row.slice(1)];
+                } else {
+                    return [value, ...fillValues];
+                }
+            });
+            if (map.size > 0) {
+                console.warn("xValues missing!", map, newRows)
+            }
+            return newRows;
+        });
+    } catch (e) {
+        console.warn(e);
+        return datas;
+    }
+}
+
 export default function lineAreaBar(element, { series, onHoverChange, onRender, chartType, isScalarSeries, settings }) {
     const colors = settings["graph.colors"];
 
-    const isTimeseries = dimensionIsTimeseries(series[0].data);
-    const isLinear = false;
+    const isTimeseries = settings["graph.x_axis.scale"] === "timeseries";
+    const isQuantitative = ["linear", "log", "pow"].indexOf(settings["graph.x_axis.scale"]) >= 0;
 
-    // validation.  we require at least 2 rows for line charting
     if (series[0].data.cols.length < 2) {
-        return;
+        throw "This chart type requires at least 2 columns";
+    }
+
+    if (series.length > 20) {
+        throw "This chart type doesn't support more than 20 series";
     }
 
     let datas = series.map((s, index) =>
         s.data.rows.map(row => [
-            (isTimeseries) ? parseTimestamp(row[0]) : String(row[0]),
-            ...row.slice(1)
+            // don't parse as timestamp if we're going to display as a quantitative scale, e.x. years and Unix timestamps
+            (settings["graph.x_axis._is_timeseries"] && !isQuantitative) ?
+                parseTimestamp(row[0], s.data.cols[0].unit).toDate()
+            : settings["graph.x_axis._is_numeric"] ?
+                row[0]
+            :
+                String(row[0])
+            , ...row.slice(1)
         ])
     );
 
+    // compute the x-values
     let xValues = getXValues(datas, chartType);
 
+    // compute the domain
+    let xDomain = d3.extent(xValues);
+
+    let xInterval;
+    if (isTimeseries) {
+        // compute the interval
+        let unit = minTimeseriesUnit(series.map(s => s.data.cols[0].unit));
+        xInterval = computeTimeseriesDataInverval(xValues, unit);
+    } else if (isQuantitative) {
+        xInterval = computeNumericDataInverval(xValues);
+    }
+
+    if (settings["line.missing"] === "zero" || settings["line.missing"] === "none") {
+        if (isTimeseries) {
+            // replace xValues with
+            xValues = d3.time[xInterval.interval]
+                .range(xDomain[0], moment(xDomain[1]).add(1, "ms").toDate(), xInterval.count);
+            datas = fillMissingValues(
+                datas,
+                xValues,
+                settings["line.missing"] === "zero" ? 0 : null,
+                (m) => d3.round(m.getTime(), -1) // sometimes rounds up 1ms?
+            );
+        } if (isQuantitative) {
+            xValues = d3.range(xDomain[0], xDomain[1] + xInterval, xInterval);
+            datas = fillMissingValues(
+                datas,
+                xValues,
+                settings["line.missing"] === "zero" ? 0 : null,
+            );
+        } else {
+            datas = fillMissingValues(
+                datas,
+                xValues,
+                settings["line.missing"] === "zero" ? 0 : null
+            );
+        }
+    }
+
+    if (isScalarSeries) {
+        xValues = datas.map(data => data[0][0]);
+    }
+
     let dimension, groups, yAxisSplit;
 
-    if (settings["stackable.stacked"] && datas.length > 1) {
+    const isScatter = chartType === "scatter";
+    const isStacked = settings["stackable.stacked"] && datas.length > 1
+
+    if (isScatter) {
+        let dataset = crossfilter();
+        datas.map(data => dataset.add(data));
+
+        dimension = dataset.dimension(d => [d[0], d[1]]);
+        groups = datas.map(data => {
+            let dim = crossfilter(data).dimension(d => d);
+            return [
+                dim.group().reduceSum((d) => d[2] || 1)
+            ]
+        });
+    } else if (isStacked) {
         let dataset = crossfilter();
         datas.map((data, i) =>
             dataset.add(data.map(d => ({
@@ -470,11 +691,9 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender,
         dimension = dataset.dimension(d => d[0]);
         groups = [
             datas.map((data, i) =>
-                dimension.group().reduceSum(d => (d[i + 1] || 0))
+                reduceGroup(dimension.group(), i + 1)
             )
         ];
-
-        yAxisSplit = [series.map((s,i) => i)];
     } else {
         let dataset = crossfilter();
         datas.map(data => dataset.add(data));
@@ -483,28 +702,25 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender,
         groups = datas.map(data => {
             let dim = crossfilter(data).dimension(d => d[0]);
             return data[0].slice(1).map((_, i) =>
-                dim.group().reduceSum(d => (d[i + 1] || 0))
-            )
+                reduceGroup(dim.group(), i + 1)
+            );
         });
-
-        let yExtents = groups.map(group => d3.extent(group[0].all(), d => d.value));
-
-        if (!isScalarSeries && settings["graph.y_axis.auto_split"] !== false) {
-            yAxisSplit = computeSplit(yExtents);
-        } else {
-            yAxisSplit = [series.map((s,i) => i)];
-        }
     }
 
-    if (isScalarSeries) {
-        xValues = datas.map(data => data[0][0]);
+    let yExtents = groups.map(group => d3.extent(group[0].all(), d => d.value));
+    let yExtent = d3.extent([].concat(...yExtents));
+
+    if (!isScalarSeries && !isScatter && !isStacked && settings["graph.y_axis.auto_split"] !== false) {
+        yAxisSplit = computeSplit(yExtents);
+    } else {
+        yAxisSplit = [series.map((s,i) => i)];
     }
 
     // HACK: This ensures each group is sorted by the same order as xValues,
     // otherwise we can end up with line charts with x-axis labels in the correct order
     // but the points in the wrong order. There may be a more efficient way to do this.
     // Don't apply to linear or timeseries X-axis since the points are always plotted in order
-    if (!isTimeseries && !isLinear) {
+    if (!isTimeseries && !isQuantitative) {
         let sortMap = new Map()
         for (const [index, key] of xValues.entries()) {
             sortMap.set(key, index);
@@ -517,24 +733,55 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender,
         }
     }
 
-    let parent;
-    if (groups.length > 1) {
-        parent = initializeChart(series[0].card, element, "compositeChart")
-    } else {
-        parent = element;
-    }
+    let parent = dc.compositeChart(element);
+    applyChartBoundary(parent, element);
+    parent.transitionDuration(0);
 
     let charts = groups.map((group, index) => {
         let chart = dc[getDcjsChartType(chartType)](parent);
 
+        // disable clicks
+        chart.onClick = () => {};
+
         chart
             .dimension(dimension)
             .group(group[0])
             .transitionDuration(0)
-            .useRightYAxis(yAxisSplit.length > 1 && yAxisSplit[1].includes(index))
+            .useRightYAxis(yAxisSplit.length > 1 && yAxisSplit[1].includes(index));
+
+        if (isScatter) {
+            chart
+                .keyAccessor((d) => d.key[0])
+                .valueAccessor((d) => d.key[1])
+
+            if (chart.radiusValueAccessor) {
+                const isBubble = datas[index][0].length > 2;
+                if (isBubble) {
+                    const BUBBLE_SCALE_FACTOR_MAX = 64;
+                    chart
+                        .radiusValueAccessor((d) => d.value)
+                        .r(d3.scale.sqrt()
+                            .domain([0, yExtent[1] * BUBBLE_SCALE_FACTOR_MAX])
+                            .range([0, 1])
+                        );
+                } else {
+                    chart.radiusValueAccessor((d) => 1)
+                    chart.MIN_RADIUS = 3
+                }
+                chart.minRadiusWithLabel(Infinity);
+            }
+        }
+
+        if (chart.defined) {
+            chart.defined(
+                settings["line.missing"] === "none" ?
+                    (d) => d.y != null :
+                    (d) => true
+            );
+        }
 
         // multiple series
-        if (groups.length > 1) {
+        if (groups.length > 1 || isScatter) {
             // multiple stacks
             if (group.length > 1) {
                 // compute shades of the assigned color
@@ -550,62 +797,90 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender,
             chart.stack(group[i])
         }
 
-        applyChartLineBarSettings(chart, settings, chartType, isLinear, isTimeseries);
+        applyChartLineBarSettings(chart, settings, chartType);
 
         return chart;
     });
 
-    let chart;
-    if (charts.length > 1) {
-        chart = parent.compose(charts);
-
-        if (!isScalarSeries) {
-            chart.on("renderlet.grouped-bar", function (chart) {
-                // HACK: dc.js doesn't support grouped bar charts so we need to manually resize/reposition them
-                // https://github.com/dc-js/dc.js/issues/558
-                let barCharts = chart.selectAll(".sub rect:first-child")[0].map(node => node.parentNode.parentNode.parentNode);
-                if (barCharts.length > 0) {
-                    let oldBarWidth = parseFloat(barCharts[0].querySelector("rect").getAttribute("width"));
-                    let newBarWidthTotal = oldBarWidth / barCharts.length;
-                    let seriesPadding =
-                        newBarWidthTotal < 4 ? 0 :
-                        newBarWidthTotal < 8 ? 1 :
-                                               2;
-                    let newBarWidth = Math.max(1, newBarWidthTotal - seriesPadding);
-
-                    chart.selectAll("g.sub rect").attr("width", newBarWidth);
-                    barCharts.forEach((barChart, index) => {
-                        barChart.setAttribute("transform", "translate(" + ((newBarWidth + seriesPadding) * index) + ", 0)");
-                    });
-                }
-            })
-        }
+    let onGoalHover = () => {};
+    if (settings["graph.show_goal"]) {
+        const goalData = [[xDomain[0], settings["graph.goal_value"]], [xDomain[1], settings["graph.goal_value"]]];
+        const goalDimension = crossfilter(goalData).dimension(d => d[0]);
+        const goalGroup = goalDimension.group().reduceSum(d => d[1]);
+        const goalIndex = charts.length;
+        let goalChart = dc.lineChart(parent)
+            .dimension(goalDimension)
+            .group(goalGroup)
+            .on('renderlet', function (chart) {
+                // remove "sub" class so the goal is not used in voronoi computation
+                chart.select(".sub._"+goalIndex)
+                    .classed("sub", false)
+                    .classed("goal", true);
+            });
+        charts.push(goalChart);
 
-        // HACK: compositeChart + ordinal X axis shenanigans
-        if (chartType === "bar") {
-            chart._rangeBandPadding(BAR_PADDING_RATIO) // https://github.com/dc-js/dc.js/issues/678
-        } else {
-            chart._rangeBandPadding(1) // https://github.com/dc-js/dc.js/issues/662
+        onGoalHover = (element) => {
+            onHoverChange(element && {
+                element: element,
+                data: [{ key: "Goal", value: settings["graph.goal"] }]
+            });
         }
+    }
+
+    let chart = parent.compose(charts);
+
+    if (groups.length > 1 && !isScalarSeries) {
+        chart.on("renderlet.grouped-bar", function (chart) {
+            // HACK: dc.js doesn't support grouped bar charts so we need to manually resize/reposition them
+            // https://github.com/dc-js/dc.js/issues/558
+            let barCharts = chart.selectAll(".sub rect:first-child")[0].map(node => node.parentNode.parentNode.parentNode);
+            if (barCharts.length > 0) {
+                let oldBarWidth = parseFloat(barCharts[0].querySelector("rect").getAttribute("width"));
+                let newBarWidthTotal = oldBarWidth / barCharts.length;
+                let seriesPadding =
+                    newBarWidthTotal < 4 ? 0 :
+                    newBarWidthTotal < 8 ? 1 :
+                                           2;
+                let newBarWidth = Math.max(1, newBarWidthTotal - seriesPadding);
+
+                chart.selectAll("g.sub rect").attr("width", newBarWidth);
+                barCharts.forEach((barChart, index) => {
+                    barChart.setAttribute("transform", "translate(" + ((newBarWidth + seriesPadding) * index) + ", 0)");
+                });
+            }
+        })
+    }
+
+    // HACK: compositeChart + ordinal X axis shenanigans
+    if (chartType === "bar") {
+        chart._rangeBandPadding(BAR_PADDING_RATIO) // https://github.com/dc-js/dc.js/issues/678
     } else {
-        chart = charts[0];
-        chart.transitionDuration(0)
-        applyChartBoundary(chart, element);
+        chart._rangeBandPadding(1) // https://github.com/dc-js/dc.js/issues/662
     }
 
     // x-axis settings
-    // TODO: we should support a linear (numeric) x-axis option
     if (isTimeseries) {
-        applyChartTimeseriesXAxis(chart, settings, series, xValues);
+        applyChartTimeseriesXAxis(chart, settings, series, xValues, xDomain, xInterval);
+    } else if (isQuantitative) {
+        applyChartQuantitativeXAxis(chart, settings, series, xValues, xDomain, xInterval);
     } else {
         applyChartOrdinalXAxis(chart, settings, series, xValues);
     }
 
     // y-axis settings
-    // TODO: if we are multi-series this could be split axis
-    applyChartYAxis(chart, settings, series, yAxisSplit);
+    let [left, right] = yAxisSplit.map(indexes => ({
+        series: indexes.map(index => series[index]),
+        extent: d3.extent([].concat(...indexes.map(index => yExtents[index])))
+    }));
+    if (left && left.series.length > 0) {
+        applyChartYAxis(chart, settings, left.series, left.extent, "left");
+    }
+    if (right && right.series.length > 0) {
+        applyChartYAxis(chart, settings, right.series, right.extent, "right");
+    }
+    const isSplitAxis = (right && right.series.length) && (left && left.series.length > 0);
 
-    applyChartTooltips(chart, (hovered) => {
+    applyChartTooltips(chart, series, (hovered) => {
         if (onHoverChange) {
             // disable tooltips on lines
             if (hovered && hovered.element && hovered.element.classList.contains("line")) {
@@ -624,7 +899,7 @@ export default function lineAreaBar(element, { series, onHoverChange, onRender,
     chart.render();
 
     // apply any on-rendering functions
-    lineAndBarOnRender(chart, settings);
+    lineAndBarOnRender(chart, settings, onGoalHover, isSplitAxis);
 
     onRender && onRender({ yAxisSplit });
 
diff --git a/frontend/src/metabase/visualizations/lib/numeric.js b/frontend/src/metabase/visualizations/lib/numeric.js
new file mode 100644
index 0000000000000000000000000000000000000000..bb671b949330e95de50d8b354b3ea793776874af
--- /dev/null
+++ b/frontend/src/metabase/visualizations/lib/numeric.js
@@ -0,0 +1,41 @@
+import { isNumeric } from "metabase/lib/schema_metadata";
+
+export function dimensionIsNumeric({ cols, rows }, i = 0) {
+    return isNumeric(cols[i]) || typeof (rows[0] && rows[0][i]) === "number";
+}
+
+export function precision(a) {
+    if (!isFinite(a)) {
+        return 0;
+    }
+    if (!a) {
+        return 0;
+    }
+    var e = 1;
+    while (Math.round(a / e) !== (a / e)) {
+        e /= 10;
+    }
+    while (Math.round(a / Math.pow(10, e)) === (a / Math.pow(10, e))) {
+        e *= 10;
+    }
+    return e;
+}
+
+export function computeNumericDataInverval(xValues) {
+    let bestPrecision = Infinity;
+    for (const value of xValues) {
+        let p = precision(value) || 1;
+        if (p < bestPrecision) {
+            bestPrecision = p;
+        }
+    }
+    return bestPrecision;
+}
+
+// logTickFormat(chart.xAxis())
+export function logTickFormat(axis) {
+    let superscript = "⁰¹²³⁴⁵⁶⁷⁸⁹";
+    let formatPower = (d) => (d + "").split("").map((c) => superscript[c]).join("");
+    let formatTick = (d) =>  10 + formatPower(Math.round(Math.log(d) / Math.LN10));
+    axis.tickFormat(formatTick);
+}
diff --git a/frontend/src/metabase/visualizations/lib/timeseries.js b/frontend/src/metabase/visualizations/lib/timeseries.js
index 7c4ee28a4d7312bb66f06e2e5e85b580c57acaf0..e76c13ae91e8b1989157c01b5674b33d4838d5fd 100644
--- a/frontend/src/metabase/visualizations/lib/timeseries.js
+++ b/frontend/src/metabase/visualizations/lib/timeseries.js
@@ -1,4 +1,3 @@
-import d3 from "d3";
 import moment from "moment";
 
 import { isDate } from "metabase/lib/schema_metadata";
@@ -10,15 +9,15 @@ const TIMESERIES_UNITS = new Set([
     "day",
     "week",
     "month",
-    "quarter"
-    // "year" // https://github.com/metabase/metabase/issues/1992
+    "quarter",
+    "year" // https://github.com/metabase/metabase/issues/1992
 ]);
 
 // investigate the response from a dataset query and determine if the dimension is a timeseries
-export function dimensionIsTimeseries({ cols, rows }) {
+export function dimensionIsTimeseries({ cols, rows }, i = 0) {
     return (
-        (isDate(cols[0]) && (cols[0].unit == null || TIMESERIES_UNITS.has(cols[0].unit))) ||
-        moment(rows[0] && rows[0][0], moment.ISO_8601).isValid()
+        (isDate(cols[i]) && (cols[i].unit == null || TIMESERIES_UNITS.has(cols[i].unit))) ||
+        moment(rows[0] && rows[0][i], moment.ISO_8601).isValid()
     );
 }
 
@@ -96,12 +95,11 @@ export function computeTimeseriesDataInverval(xValues, unit) {
     return TIMESERIES_INTERVALS[computeTimeseriesDataInvervalIndex(xValues, unit)];
 }
 
-export function computeTimeseriesTicksInterval(xValues, unit, chartWidth, minPixels) {
+export function computeTimeseriesTicksInterval(xDomain, xInterval, chartWidth, minPixels) {
     // If the interval that matches the data granularity results in too many ticks reduce the granularity until it doesn't.
     // TODO: compute this directly instead of iteratively
     let maxTickCount = Math.round(chartWidth / minPixels);
-    let xDomain = d3.extent(xValues);
-    let index = computeTimeseriesDataInvervalIndex(xValues, unit);
+    let index = TIMESERIES_INTERVALS.indexOf(xInterval);
     while (index < TIMESERIES_INTERVALS.length - 1) {
         let interval = TIMESERIES_INTERVALS[index];
         let intervalMs = moment(0).add(interval.count, interval.interval).valueOf();
diff --git a/frontend/test/unit/visualizations/lib/numeric.spec.js b/frontend/test/unit/visualizations/lib/numeric.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..9c37e860835c733a4271676c7da2ed026bd5f77d
--- /dev/null
+++ b/frontend/test/unit/visualizations/lib/numeric.spec.js
@@ -0,0 +1,47 @@
+import {
+    precision,
+    computeNumericDataInverval
+} from 'metabase/visualizations/lib/numeric';
+
+describe('visualization.lib.numeric', () => {
+    describe('precision', () => {
+        const CASES = [
+            [0,     0],
+            [10,    10],
+            [-10,   10],
+            [1,     1],
+            [-1,    1],
+            [0.1,   0.1],
+            [-0.1,  0.1],
+            [0.01,  0.01],
+            [-0.01, 0.01],
+            [1.1,   0.1],
+            [-1.1,  0.1],
+            [0.5,   0.1],
+            [0.9,   0.1],
+            [-0.5,  0.1],
+            [-0.9,  0.1],
+        ];
+        for (const c of CASES) {
+            it("precision of " + c[0] + " should be " + c[1], () => {
+                expect(precision(c[0])).toEqual(c[1]);
+            });
+        }
+    });
+    describe('computeNumericDataInverval', () => {
+        const CASES = [
+            [[0],       1],
+            [[1],       1],
+            [[0, 1],       1],
+            [[0.1, 1],  0.1],
+            [[0.1, 10], 0.1],
+            [[10, 1],   1],
+            [[0, null, 1], 1]
+        ];
+        for (const c of CASES) {
+            it("precision of " + c[0] + " should be " + c[1], () => {
+                expect(computeNumericDataInverval(c[0])).toEqual(c[1]);
+            });
+        }
+    });
+});
diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json
index 0c060253bdcb88df04f5b944d2dcdfae28915838..b76f3dce6df45b647f0745309e499fe8f744de01 100644
--- a/npm-shrinkwrap.json
+++ b/npm-shrinkwrap.json
@@ -5101,7 +5101,7 @@
       "version": "3.5.17"
     },
     "dc": {
-      "version": "2.0.0-beta.31",
+      "version": "2.0.0-beta.32",
       "dependencies": {
         "crossfilter2": {
           "version": "1.3.14"
@@ -5251,10 +5251,10 @@
           }
         },
         "espree": {
-          "version": "3.1.7",
+          "version": "3.1.6",
           "dependencies": {
             "acorn": {
-              "version": "3.3.0"
+              "version": "3.2.0"
             },
             "acorn-jsx": {
               "version": "3.0.1"
@@ -5268,14 +5268,11 @@
           "version": "2.0.2"
         },
         "file-entry-cache": {
-          "version": "1.3.1",
+          "version": "1.2.4",
           "dependencies": {
             "flat-cache": {
-              "version": "1.2.1",
+              "version": "1.0.10",
               "dependencies": {
-                "circular-json": {
-                  "version": "0.3.1"
-                },
                 "del": {
                   "version": "2.2.1",
                   "dependencies": {
@@ -5318,12 +5315,15 @@
                       }
                     },
                     "rimraf": {
-                      "version": "2.5.4"
+                      "version": "2.5.3"
                     }
                   }
                 },
                 "graceful-fs": {
-                  "version": "4.1.5"
+                  "version": "4.1.4"
+                },
+                "read-json-sync": {
+                  "version": "1.1.1"
                 },
                 "write": {
                   "version": "0.2.1"
@@ -5356,10 +5356,10 @@
               "version": "3.0.2",
               "dependencies": {
                 "brace-expansion": {
-                  "version": "1.1.6",
+                  "version": "1.1.5",
                   "dependencies": {
                     "balanced-match": {
-                      "version": "0.4.2"
+                      "version": "0.4.1"
                     },
                     "concat-map": {
                       "version": "0.0.1"
@@ -5561,7 +5561,7 @@
           }
         },
         "lodash": {
-          "version": "4.14.1"
+          "version": "4.13.1"
         },
         "mkdirp": {
           "version": "0.5.1",
@@ -5578,7 +5578,7 @@
               "version": "0.1.3"
             },
             "fast-levenshtein": {
-              "version": "1.1.4"
+              "version": "1.1.3"
             },
             "prelude-ls": {
               "version": "1.1.2"
diff --git a/package.json b/package.json
index 7978a8e6786fa6a9f8b5805d42ca66c448a44f62..8cb58e31cb08da69d0471622a0e0283d20d7fbb1 100644
--- a/package.json
+++ b/package.json
@@ -21,8 +21,8 @@
     "classnames": "^2.1.3",
     "color": "^0.11.1",
     "crossfilter": "^1.3.12",
-    "d3": "^3.5.16",
-    "dc": "^2.0.0-beta.25",
+    "d3": "^3.5.17",
+    "dc": "^2.0.0-beta.32",
     "diff": "^2.2.1",
     "fixed-data-table": "^0.6.0",
     "history": "^3.0.0",