diff --git a/src/examples/aster_plot/README.md b/src/examples/aster_plot/README.md new file mode 100644 index 00000000..4eee4da6 --- /dev/null +++ b/src/examples/aster_plot/README.md @@ -0,0 +1,26 @@ +# Aster Plot for Looker + +Aster plots are used to display two metrics per slice in a pie chart visualisation. Each pie slice has a length and width component. + +The first measure, the *length*, represents the **score** of each slice, is the value extending from the centre of the pie outwards to the edge (*0* is the centre and the outer circle length is defaulted to max value of all scores). + +The second measure, the *width*, represents the **weight** of each slice, which gets used to arrive at a weighted mean score of the length scores in the centre. This score can also be overriden by entering a custom keyword search to use a row level value's length in place of the weighted mean score. See example below. + +This implementation for Looker was built based on the [Ben Best’s Aster Plot in D3 Block](http://bl.ocks.org/bbest/2de0e25d4840c68f2db1). + + +## Example +![Screenshot](https://github.com/davidtamaki/aster_plot/blob/master/screen-shots/aster_example.gif) + + +## Implementation Instructions +1. Fork this repository + +2. Turn on [GitHub Pages](https://help.github.com/articles/configuring-a-publishing-source-for-github-pages/) + +3. Follow directions on Looker's documentation to add a [new custom visualisation manifest](https://docs.looker.com/admin-options/platform/visualizations#adding_a_new_custom_visualization_manifest): + - In the 'Main' field, the URI of the visualization will be `https://DOMAIN_NAME/aster_plot/aster_plot.js`. For example: https://davidtamaki.github.io/aster_plot/aster_plot.js + - The required dependencies are: + - [d3](https://d3js.org/d3.v3.min.js) + - [d3-tip](https://cdnjs.cloudflare.com/ajax/libs/d3-tip/0.9.1/d3-tip.min.js) + diff --git a/src/examples/aster_plot/aster_plot.js b/src/examples/aster_plot/aster_plot.js new file mode 100644 index 00000000..a3603c6e --- /dev/null +++ b/src/examples/aster_plot/aster_plot.js @@ -0,0 +1,557 @@ +looker.plugins.visualizations.add({ + //plot and series (colors) + options: { + radius: { + section: "Data", + order: 1, + type: "number", + label: "Circle Radius" + }, + keyword_search: { + section: "Data", + order: 2, + type: "string", + label: "Custom keyword to search for", + placeholder: "Enter row value to display score" + }, + label_value: { + section: "Data", + order: 3, + type: "string", + label: "Data Labels", + values: [ + {"On":"on"}, + {"Off":"off"} + ], + display: "radio", + default: "off" + }, + legend: { + section: "Data", + order: 4, + type: "string", + label: "Legend", + values: [ + { "Left": "left" }, + { "Right": "right" }, + { "Off": "off"} + ], + display: "radio", + default: "off" + }, + color_range: { + section: "Format", + order: 1, + type: "array", + label: "Color Range", + display: "colors", + default: ["#9E0041", "#C32F4B", "#E1514B", "#F47245", "#FB9F59", "#FEC574", "#FAE38C", "#EAF195", "#C7E89E", "#9CD6A4", "#6CC4A4", "#4D9DB4", "#4776B4", "#5E4EA1"] + }, + chart_size: { + section: "Format", + order: 2, + type: "string", + label: "Chart Size", + default: '100%' + }, + inner_circle_color: { + section: "Inner Circle", + order: 1, + type: "string", + label: "Circle Color", + display: "color", + default: "#ffffff" + }, + text_color: { + section: "Inner Circle", + order: 2, + type: "string", + label: "Text Color", + display: "color", + default: "#000000" + }, + font_size: { + section: "Inner Circle", + order: 3, + type: "number", + label: "Font Size", + display: "range", + min: 10, + max: 100, + default: 40 + } + }, + + // Set up the initial state of the visualization + create: function(element, config) { + + var css = ` + `; + + element.innerHTML = css; + var container = element.appendChild(document.createElement("div")); // Create a container element to let us center the text. + this.container = container + container.className = "d3-aster-plot"; + this._textElement = container.appendChild(document.createElement("div")); // Create an element to contain the text. + }, + + + // Render in response to the data or settings changing + updateAsync: function(data, element, config, queryResponse, details, done) { + this.container.innerHTML = '' // clear container of previous vis + this.clearErrors(); // clear any errors from previous updates + + // ensure data fit - requires no pivots, exactly 1 dimension_like field, and exactly 2 measure_like fields + if (!handleErrors(this, queryResponse, { + min_pivots: 0, max_pivots: 0, + min_dimensions: 1, max_dimensions: 1, + min_measures: 2, max_measures: 2})) { + return; + } + + var dimension = queryResponse.fields.dimension_like[0].name; + var measure_1_score = queryResponse.fields.measure_like[0].name, measure_2_weight = queryResponse.fields.measure_like[1].name; + + // SVG margins to make labels visible. Otherwise they overflow visible area + // src: https://www.visualcinnamon.com/2015/09/placing-text-on-arcs.html + var margin = { + top: 30, + right: 30, + bottom: 30, + left: 30 + }; + + var width = element.clientWidth - margin.left - margin.right, + height = element.clientHeight - margin.top - margin.bottom, + radius = Math.min(width, height) / 2, + innerRadius = 0.3 * radius; + + // set custom chart size + if (!isNaN(parseFloat(config.chart_size))) { + var ratio = parseFloat(config.chart_size) / 100.0; + if (ratio > 2) { + radius = radius*2; + } + else if (ratio < 0.2) { + radius = radius*0.2; + } + else { + radius = radius*ratio; + } + } + + if (!config.color_range) { + config.color_range = ["#9E0041", "#C32F4B", "#E1514B", "#F47245", "#FB9F59", "#FEC574", "#FAE38C", "#EAF195", "#C7E89E", "#9CD6A4", "#6CC4A4", "#4D9DB4", "#4776B4", "#5E4EA1"]; + } + + var all_scores = [], + all_weight = [], + color_length = config.color_range.length, + dataset_tiny = {}; + for (let i = 0; i < data.length; i++) { + if (i >= color_length) { + let j = Math.floor(i/color_length) + data[i].color = config.color_range[i-(j*color_length)]; // loop through color array if there are too many series + } else { + data[i].color = config.color_range[i]; + } + data[i].label = data[i][dimension].value; // dimension label + data[i].score = +data[i][measure_1_score].value; // length of slice (circle radius default is 100) + data[i].weight = +data[i][measure_2_weight].value; // angle of slice (width of slice) + data[i].width = +data[i][measure_2_weight].value; // angle of slice (width of slice) + data[i].rendered = data[i][measure_1_score].rendered; // used for tooltip and legened + all_scores.push(data[i][measure_1_score].value); // used to set max radius + all_weight.push(data[i][measure_2_weight].value); // used to set custom inner circle size + dataset_tiny[data[i][dimension].value] = data[i][measure_1_score].rendered; + } + + if (!config.radius) { + console.log('Radius not set. Defaulting to max score: ' + getMaxOfArray(all_scores)) + config.radius = getMaxOfArray(all_scores) + } else { + console.log('Radius config set to: ' + config.radius) + } + + // calculate the weighted mean score (value in centre of pie) + if (!config.keyword_search) { + // console.log('Default weighted mean score') + var score = + Math.round( + data.reduce(function(a, b) { + //console.log('a:' + a + ', b.score: ' + b.score + ', b.weight: ' + b.weight); + return a + (b.score * b.weight); + }, 0) / + data.reduce(function(a, b) { + return a + b.weight; + }, 0) + ); + } else { + // custom keyword option + for (let i = 0; i < data.length; i++) { + if (data[i].label.toLowerCase().includes(config.keyword_search.toLowerCase())) { + console.log(data[i].label + ' is used for centre score'); + var score = data[i].weight, + min = Math.min( ...all_weight), + max = Math.max( ...all_weight), + scale_min = 0.2, // setting scale is 0.2 -> 0.6 of radius + scale_max = 0.6, + diff = max-min, + percentile = (score-min)/diff; + data.splice(i,1); + innerRadius = ((percentile * 0.4) + scale_min) * radius; // reshape inner circle size based on weight + break; + } + } + } + + var pie = d3.layout.pie() + .sort(null) + .value(function(d) { + return d.width; + }); + + var tip = d3.tip() + .attr('class', 'd3-tip') + .offset([0, 0]) + .html(function(d) { + return d.data.label + ": " + d.data.rendered + ""; + }); + + var arc = d3.svg.arc() + .innerRadius(innerRadius) + .outerRadius(function(d) { + return (radius - innerRadius) * (d.data.score / (1.0*config.radius)) + innerRadius; + }); + + var outlineArc = d3.svg.arc() + .innerRadius(innerRadius) + .outerRadius(radius); + + var svg = d3.select(".d3-aster-plot").append("svg") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", "translate(" + (width / 2 + margin.left) + "," + (height / 2 + margin.top) + ")"); + + svg.call(tip); + + // inner circle color + var inner_circle = svg.append("circle") + .attr("cx", 0) + .attr("cy", 0) + .attr("r", innerRadius) + .attr("fill",config.inner_circle_color); + + // affix score to centre of pie + svg.append("svg:text") + .attr("class", "aster-score") + .attr("dy", ".35em") + .attr("text-anchor", "middle") // text-align: right + .style('fill', config.text_color) + .attr("font-size", config.font_size) + .text(score); + + var path = svg.selectAll(".solidArc") + .data(pie(data)) + .enter().append("path") + .attr("data-legend",function(d) { return d.data.label }) // for legend + .attr("fill", function(d) { return d.data.color }) + .attr("class", "solidArc") + .attr("stroke", "gray") + .attr("d", arc) + .on('mouseover', tip.show) + .on('mouseout', tip.hide); + + // Create the Ouline Arc and also the invisible arcs for labels + // src: https://www.visualcinnamon.com/2015/09/placing-text-on-arcs.html + var outerPath = svg.selectAll(".outlineArc") + .data(pie(data)) + .enter().append("path") + .attr("fill", "none") + .attr("stroke", "gray") + .attr("class", "outlineArc") + .attr("d", outlineArc) + // Create the new invisible arcs and flip the direction for the bottom half labels + .each(function(d, i) { + // Search pattern for everything between the start and the first capital L + var firstArcSection = /(^.+?)L/; + + // Grab everything up to the first Line statement + var newArc = firstArcSection.exec( d3.select(this).attr("d") )[1]; + // Replace all the commas so that IE can handle it + newArc = newArc.replace(/,/g , " "); + + if (shouldFlipLabel(d.startAngle, d.endAngle)) { + // Arc path + // Template: M start-x, start-y A radius-x, radius-y, x-axis-rotation, large-arc-flag, sweep-flag, end-x, end-y + // Example: M 0 300 A 200 200 0 0 1 400 300 + + // Everything between the capital M and first capital A + var startLoc = /M(.*?)A/; + // Everything between the capital A and 0 0 1 + var middleLoc = /A(.*?)0 0 1/; + // Everything between the 0 0 1 and the end of the string (denoted by $) + var endLoc = /0 0 1 (.*?)$/; + // Flip the direction of the arc by switching the start and end point + // and using a 0 (instead of 1) sweep flag + var newStart = endLoc.exec( newArc )[1]; + var newEnd = startLoc.exec( newArc )[1]; + var middleSec = middleLoc.exec( newArc )[1]; + + // Build up the new arc notation, set the sweep-flag to 0 + newArc = "M" + newStart + "A" + middleSec + "0 0 0 " + newEnd; + } + + // Create a new invisible arc that the label can flow along + svg.append("path") + .attr("class", "hiddenDonutArcs") + .attr("id", "sliceOutlineArc_"+i) + .attr("d", newArc) + .style("fill", "none"); + }); + + if (config.label_value == "on") { + // Create labels + // src: https://www.visualcinnamon.com/2015/09/placing-text-on-arcs.html + + // Line 1 + svg.selectAll(".label-line-1") + .data(pie(data)) + .enter() + .append("text") + .attr("class", "label-line-1") + // Move the labels below the arcs + .attr("dy", function(d,i) { + return shouldFlipLabel(d.startAngle, d.endAngle) + ? 18 + : -21 + }) + .append("textPath") + .attr("startOffset","50%") + .style("text-anchor","middle") + .attr("xlink:href", function(d, i) { return "#sliceOutlineArc_"+i; }) + .text(function(d) { return d.data.label; }); + + // Line 2 + svg.selectAll(".label-line-2") + .data(pie(data)) + .enter() + .append("text") + .attr("class", "label-line-2") + // Move the labels below the arcs + .attr("dy", function(d,i) { + return shouldFlipLabel(d.startAngle, d.endAngle) + ? 28 + : -11 + }) + .append("textPath") + .attr("startOffset","50%") + .style("text-anchor","middle") + .attr("xlink:href", function(d, i) { return "#sliceOutlineArc_"+i; }) + .text(function(d) { return d.data.rendered; }); + } + + // legend + if (config.legend == "left") { + applyLegend(-width/2.2) + } else if (config.legend == "right") { + applyLegend(width/3.0) + } + + + // Helper functions + + // Flip the end and start position + // + // We do not flip slices that more than 180 to not think about condition of how to flip + // them. Custom condition is required because > 180 slices have "large-arc-flag" set to 1, + // but we handle only case when it's set to 0 (Look at "0 0 1") + function shouldFlipLabel(startAngle, endAngle) { + return ( + // End angle lies beyond a quarter of a circle (90 degrees or pi/2) + radiansToDegrees(endAngle) > 90 && + radiansToDegrees(endAngle) < 270 && + // Slice "length" is less than 180 degrees + radiansToDegrees(endAngle - startAngle) < 180 + ); + } + + function radiansToDegrees(ragiansAngle) { + return ragiansAngle * 180 / Math.PI; + } + + function getMaxOfArray(numArray) { + return Math.max.apply(null, numArray); + } + + function handleErrors(vis, res, options) { + var check = function (group, noun, count, min, max) { + if (!vis.addError || !vis.clearErrors) { + return false; + } + if (count < min) { + vis.addError({ + title: "Not Enough " + noun + "s", + message: "This visualization requires " + (min === max ? 'exactly' : 'at least') + " " + min + " " + noun.toLowerCase() + (min === 1 ? '' : 's') + ".", + group: group + }); + return false; + } + if (count > max) { + vis.addError({ + title: "Too Many " + noun + "s", + message: "This visualization requires " + (min === max ? 'exactly' : 'no more than') + " " + max + " " + noun.toLowerCase() + (min === 1 ? '' : 's') + ".", + group: group + }); + return false; + } + vis.clearErrors(group); + return true; + }; + var _a = res.fields, pivots = _a.pivots, dimensions = _a.dimension_like, measures = _a.measure_like; + return (check('pivot-req', 'Pivot', pivots.length, options.min_pivots, options.max_pivots) + && check('dim-req', 'Dimension', dimensions.length, options.min_dimensions, options.max_dimensions) + && check('mes-req', 'Measure', measures.length, options.min_measures, options.max_measures)); + } + + function applyLegend(horizontalScale) { + var legend = svg.append("g") + .attr("class","legend") + .attr("transform","translate(" + horizontalScale + " ,-" + height/2.5 + ")") + .style("font-size","12px") + .call(d3legend) + } + + // Legend + // (C) 2012 ziggy.jonsson.nyc@gmail.com + // MIT licence + function d3legend(g) { + g.each(function() { + var g= d3.select(this), + items = {}, + svg = d3.select(g.property("nearestViewportElement")), + legendPadding = g.attr("data-style-padding") || 5, + lb = g.selectAll(".legend-box").data([true]), + li = g.selectAll(".legend-items").data([true]) + + lb.enter().append("rect").classed("legend-box",true) + li.enter().append("g").classed("legend-items",true) + + svg.selectAll("[data-legend]").each(function() { + var self = d3.select(this) + items[self.attr("data-legend")] = { + pos : self.attr("data-legend-pos") || this.getBBox().y, + color : self.attr("data-legend-color") != undefined ? self.attr("data-legend-color") : self.style("fill") != 'none' ? self.style("fill") : self.style("stroke"), + rendered : '100' // testing adding values to legend + } + }) + + // sort alphanumerically + items = d3.entries(items).sort(function(a,b) { return (a.key < b.key) ? -1 : (a.key > b.key) ? 1 : 0}) + + // adding rendered values to legend + for (let i = 0; i < items.length; i++) { + items[i].value.rendered = dataset_tiny[items[i].key] + } + + li.selectAll("text") + .data(items,function(d) { return d.key}) + .call(function(d) { d.enter().append("text")}) + .call(function(d) { d.exit().remove()}) + .attr("y",function(d,i) { return i+"em"}) + .attr("x","1em") + .text(function(d) { return d.key + ' ' + d.value.rendered}); + + li.selectAll("circle") + .data(items,function(d) { return d.key}) + .call(function(d) { d.enter().append("circle")}) + .call(function(d) { d.exit().remove()}) + .attr("cy",function(d,i) { return i-0.25+"em"}) + .attr("cx",0) + .attr("r","0.4em") + .style("fill",function(d) { return d.value.color}); + + // Reposition and resize the box + var lbbox = li[0][0].getBBox() + lb.attr("x",(lbbox.x-legendPadding)) + .attr("y",(lbbox.y-legendPadding)) + .attr("height",(lbbox.height+2*legendPadding)) + .attr("width",(lbbox.width+2*legendPadding)) + }) + return g + } + + done() + } +}); diff --git a/src/examples/aster_plot/screen-shots/aster_example.gif b/src/examples/aster_plot/screen-shots/aster_example.gif new file mode 100644 index 00000000..e608b640 Binary files /dev/null and b/src/examples/aster_plot/screen-shots/aster_example.gif differ