Ver Fonte

Add Series chart operation

toby há 8 anos atrás
pai
commit
6784a1c027
2 ficheiros alterados com 240 adições e 1 exclusões
  1. 33 0
      src/core/config/OperationConfig.js
  2. 207 1
      src/core/operations/Charts.js

+ 33 - 0
src/core/config/OperationConfig.js

@@ -3558,6 +3558,39 @@ const OperationConfig = {
             },
         ]
     },
+    "Series chart": {
+        description: [].join("\n"),
+        run: Charts.runSeriesChart,
+        inputType: "string",
+        outputType: "html",
+        args: [
+            {
+                name: "Record delimiter",
+                type: "option",
+                value: Charts.RECORD_DELIMITER_OPTIONS,
+            },
+            {
+                name: "Field delimiter",
+                type: "option",
+                value: Charts.FIELD_DELIMITER_OPTIONS,
+            },
+            {
+                name: "X label",
+                type: "string",
+                value: "",
+            },
+            {
+                name: "Point radius",
+                type: "number",
+                value: 1,
+            },
+            {
+                name: "Series colours",
+                type: "string",
+                value: "mediumseagreen, dodgerblue, tomato",
+            },
+        ]
+    },
     "HTML to Text": {
         description: [].join("\n"),
         run: HTML.runHTMLToText,

+ 207 - 1
src/core/operations/Charts.js

@@ -103,7 +103,7 @@ const Charts = {
         return { headings, values };
     },
 
-    
+
     /**
      * Gets values from input for a scatter plot with colour from the third column.
      *
@@ -140,6 +140,50 @@ const Charts = {
     },
 
 
+    /**
+     * Gets values from input for a time series plot.
+     *
+     * @param {string} input
+     * @param {string} recordDelimiter
+     * @param {string} fieldDelimiter
+     * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record
+     * @returns {Object[]}
+     */
+    _getSeriesValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) {
+        let { headings, values } = Charts._getValues(
+            input,
+            recordDelimiter, fieldDelimiter,
+            false,
+            3
+        );
+
+        let xValues = new Set(),
+            series = {};
+
+        values = values.forEach(row => {
+            let serie = row[0],
+                xVal = row[1],
+                val = parseFloat(row[2], 10);
+
+            if (Number.isNaN(val)) throw "Values must be numbers in base 10.";
+
+            xValues.add(xVal);
+            if (typeof series[serie] === "undefined") series[serie] = {};
+            series[serie][xVal] = val;
+        });
+
+        xValues = new Array(...xValues);
+
+        const seriesList = [];
+        for (let seriesName in series) {
+            let serie = series[seriesName];
+            seriesList.push({name: seriesName, data: serie});
+        }
+
+        return { xValues, series: seriesList };
+    },
+
+
     /**
      * Hex Bin chart operation.
      *
@@ -630,6 +674,168 @@ const Charts = {
 
         return svg._groups[0][0].outerHTML;
     },
+
+
+    /**
+     * Series chart operation.
+     *
+     * @param {string} input
+     * @param {Object[]} args
+     * @returns {html}
+     */
+    runSeriesChart(input, args) {
+        const recordDelimiter = Utils.charRep[args[0]],
+            fieldDelimiter = Utils.charRep[args[1]],
+            xLabel = args[2],
+            pipRadius = args[3],
+            seriesColours = args[4].split(","),
+            svgWidth = 500,
+            interSeriesPadding = 20,
+            xAxisHeight = 50,
+            seriesLabelWidth = 50,
+            seriesHeight = 100,
+            seriesWidth = svgWidth - seriesLabelWidth - interSeriesPadding;
+
+        let { xValues, series } = Charts._getSeriesValues(input, recordDelimiter, fieldDelimiter),
+            allSeriesHeight = Object.keys(series).length * (interSeriesPadding + seriesHeight),
+            svgHeight = allSeriesHeight + xAxisHeight + interSeriesPadding;
+
+        let svg = document.createElement("svg");
+        svg = d3.select(svg)
+            .attr("width", "100%")
+            .attr("height", "100%")
+            .attr("viewBox", `0 0 ${svgWidth} ${svgHeight}`);
+
+        let xAxis = d3.scalePoint()
+            .domain(xValues)
+            .range([0, seriesWidth]);
+
+        svg.append("g")
+            .attr("class", "axis axis--x")
+            .attr("transform", `translate(${seriesLabelWidth}, ${xAxisHeight})`)
+            .call(
+                d3.axisTop(xAxis).tickValues(xValues.filter((x, i) => {
+                    return [0, Math.round(xValues.length / 2), xValues.length -1].indexOf(i) >= 0;
+                }))
+            );
+
+        svg.append("text")
+            .attr("x", svgWidth / 2)
+            .attr("y", xAxisHeight / 2)
+            .style("text-anchor", "middle")
+            .text(xLabel);
+
+        let tooltipText = {},
+            tooltipAreaWidth = seriesWidth / xValues.length;
+
+        xValues.forEach(x => {
+            let tooltip = [];
+
+            series.forEach(serie => {
+                let y = serie.data[x];
+                if (typeof y === "undefined") return;
+
+                tooltip.push(`${serie.name}: ${y}`);
+            });
+
+            tooltipText[x] = tooltip.join("\n");
+        });
+
+        let chartArea = svg.append("g")
+            .attr("transform", `translate(${seriesLabelWidth}, ${xAxisHeight})`);
+
+        chartArea
+            .append("g")
+            .selectAll("rect")
+            .data(xValues)
+            .enter()
+            .append("rect")
+            .attr("x", x => {
+                return xAxis(x) - (tooltipAreaWidth / 2);
+            })
+            .attr("y", 0)
+            .attr("width", tooltipAreaWidth)
+            .attr("height", allSeriesHeight)
+            .attr("stroke", "none")
+            .attr("fill", "transparent")
+            .append("title")
+            .text(x => {
+                return `${x}\n
+                    --\n
+                    ${tooltipText[x]}\n
+                `.replace(/\s{2,}/g, "\n");
+            });
+
+        let yAxesArea = svg.append("g")
+            .attr("transform", `translate(0, ${xAxisHeight})`);
+
+        series.forEach((serie, seriesIndex) => {
+            let yExtent = d3.extent(Object.values(serie.data)),
+                yAxis = d3.scaleLinear()
+                    .domain(yExtent)
+                    .range([seriesHeight, 0]);
+
+            let seriesGroup = chartArea
+                .append("g")
+                .attr("transform", `translate(0, ${seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`);
+
+            let path = "";
+            xValues.forEach((x, xIndex) => {
+                let nextX = xValues[xIndex + 1],
+                    y = serie.data[x],
+                    nextY= serie.data[nextX];
+
+                if (typeof y === "undefined" || typeof nextY === "undefined") return;
+
+                x = xAxis(x); nextX = xAxis(nextX);
+                y = yAxis(y); nextY = yAxis(nextY);
+
+                path += `M ${x} ${y} L ${nextX} ${nextY} z `;
+            });
+
+            seriesGroup
+                .append("path")
+                .attr("d", path)
+                .attr("fill", "none")
+                .attr("stroke", seriesColours[seriesIndex % seriesColours.length])
+                .attr("stroke-width", "1");
+
+            xValues.forEach(x => {
+                let y = serie.data[x];
+                if (typeof y === "undefined") return;
+
+                seriesGroup
+                    .append("circle")
+                    .attr("cx", xAxis(x))
+                    .attr("cy", yAxis(y))
+                    .attr("r", pipRadius)
+                    .attr("fill", seriesColours[seriesIndex % seriesColours.length])
+                    .append("title")
+                    .text(d => {
+                        return `${x}\n
+                            --\n
+                            ${tooltipText[x]}\n
+                        `.replace(/\s{2,}/g, "\n");
+                    });
+            });
+
+            yAxesArea
+                .append("g")
+                .attr("transform", `translate(${seriesLabelWidth - interSeriesPadding}, ${seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`)
+                .attr("class", "axis axis--y")
+                .call(d3.axisLeft(yAxis).ticks(5));
+
+            yAxesArea
+                .append("g")
+                .attr("transform", `translate(0, ${seriesHeight / 2 + seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`)
+                .append("text")
+                .style("text-anchor", "middle")
+                .attr("transform", "rotate(-90)")
+                .text(serie.name);
+        });
+
+        return svg._groups[0][0].outerHTML;
+    },
 };
 
 export default Charts;