0?e[0][t.fields.measure_like[s].name].value:0,r.push(i)}Highcharts.setOptions({lang:{thousandsSep:","}}),console.log(n);var f=Highcharts.chart("looker-waterfall-chart",{colors:a.color_range,chart:{type:"waterfall"},credits:!1,title:{text:null},exporting:{enabled:!1},xAxis:{type:"category"},yAxis:{min:0,visible:!1},legend:{enabled:!1},tooltip:{formatter:function(){return"$"+nFormatter(this.y)}},plotOptions:{waterfall:{borderColor:"#FFFFFF",dataLabels:{style:{color:"#000000",textOutline:"0px contrast",fontFamily:"Open Sans"}}}},series:[{upColor:"#98FFAE",color:"red",data:r,pointPadding:0,dataLabels:{enabled:!0,formatter:function(){return"$"+nFormatter(this.y)}}}]});a.dotted_line&&f.yAxis[0].addPlotLine({value:n,color:"green",dashStyle:"shortdash",width:2,visible:a.dotted_line,label:{text:"Cumulative In Pipeline: $"+nFormatter(n)}}),a.start_end_band&&f.yAxis[0].addPlotBand({from:e[0][t.fields.measure_like[0].name].value,to:-1*e[0][t.fields.measure_like[t.fields.measure_like.length-1].name].value,color:"#FCFFC5",id:"plot-band-1",label:{text:"Amount Changed: $"+nFormatter(e[0][t.fields.measure_like[0].name].value+e[0][t.fields.measure_like[t.fields.measure_like.length-1].name].value),verticalAlign:"middle",align:"center"}})}};looker.plugins.visualizations.add(e)}();
--------------------------------------------------------------------------------
/dist/spider.js:
--------------------------------------------------------------------------------
1 | function RadarChart(e,t,a,l,n,i,o,d){var c={w:600,h:600,margin:{top:20,right:20,bottom:20,left:20},levels:3,maxValue:0,labelFactor:1.325,wrapWidth:60,opacityArea:.15,dotRadius:5,opacityCircles:.15,strokeWidth:2,roundStrokes:!0,color:d3.scale.category10(),legendSide:"left",glow:2,negatives:!0,axisColor:"#CDCDCD",backgroundColor:"#CDCDCD",negativeR:.81,independent:!0,axisFont:2,scaleFont:1,legendPad:10,legendFont:.8,domainMax:null,labelScale:!0};if(void 0!==a)for(var u in a)void 0!==a[u]&&(c[u]=a[u]);var p=c.domainMax?Math.max(c.domainMax,d3.max(t,function(e){return d3.max(e.map(function(e){return e.value}))})):d3.max(t,function(e){return d3.max(e.map(function(e){return e.value}))}),f=Math.min(c.w,c.h),g=t[0].map(function(e,t){return e.axis}),y=g.length,h=Math.min(.35*f,"center"===c.legendSide?.3*f:.45*f),v=d3.format(",.0f"),m=2*Math.PI/y;if(c.independent)axesMax=[],t.forEach(function(e){e.forEach(function(e){e.axis in axesMax?Math.abs(e.value)>axesMax[e.axis]&&(axesMax[e.axis]=Math.abs(e.value)):axesMax[e.axis]=Math.abs(e.value)})}),rScale=[],g.map(function(e){rScale.push(d3.scale.linear().range([0,h]).domain([0,axesMax[e]]))}),p=[],g.map(function(e){p.push(axesMax[e])});else{var x=c.domainMax?Math.max(c.domainMax,d3.max(t,function(e){return d3.max(e.map(function(e){return e.value}))})):d3.max(t,function(e){return d3.max(e.map(function(e){return e.value}))});rScale=[],g.map(function(e){rScale.push(d3.scale.linear().range([0,h]).domain([0,p]))}),p=[],g.map(function(e){p.push(x)})}d3.select(e).select("svg").remove();var b=d3.select(e).append("svg").attr("width",c.w).attr("height",c.h+c.margin.bottom).attr("class","radar"+e),_="center"===c.legendSide?c.h/2-c.margin.top:c.h/2+c.margin.top,M=b.append("g").attr("transform","translate("+c.w/2+","+_+")"),S=M.append("defs").append("filter").attr("id","glow"),A=(S.append("feGaussianBlur").attr("stdDeviation",`${c.glow}`).attr("result","coloredBlur"),S.append("feMerge")),C=(A.append("feMergeNode").attr("in","coloredBlur"),A.append("feMergeNode").attr("in","SourceGraphic"),M.append("g").attr("class","axisWrapper"));c.independent?g.forEach(function(e,t){d3.range(1,c.levels+1).reverse().forEach(function(e,a){C.append("text").attr("class","axisLabel").attr("x",e/c.levels*h*Math.cos(m*t-Math.PI/2)).attr("y",e/c.levels*h*Math.sin(m*t-Math.PI/2)).attr("dy","0.35em").attr("dy","0.35em").style("font-size",`${c.labelScale?c.scaleFont:0}px`).style("font-weight","900").style("font-family","Open Sans").style("z-index",10).attr("fill",c.axisColor).text(v(p[t]*e/c.levels))})}):C.selectAll(".axisLabel").data(d3.range(1,c.levels+1).reverse()).enter().append("text").attr("class","axisLabel").attr("x",4).attr("y",function(e){return-e*h/c.levels}).attr("dy","0.4em").style("font-size",`${c.labelScale?c.scaleFont:0}px`).style("font-weight","900").style("font-family","Open Sans").style("z-index",10).attr("fill",c.axisColor).text(function(e){return v(p[0]*e/c.levels)});var k=1;c.roundStrokes?k=1:3==y?k=.5:5==y?k=.81:7==y?k=.9:9==y?k=.94:11==y&&(k=.96);var w=C.selectAll(".axis").data(g).enter().append("g").attr("class","axis");w.append("line").attr("x1",function(e,t){return c.roundStrokes?c.negatives?rScale[t](-1*p[t])*Math.cos(m*t-Math.PI/2):0:c.negatives?rScale[t](p[t]*-k)*Math.cos(m*t-Math.PI/2):0}).attr("y1",function(e,t){return c.roundStrokes?c.negatives?rScale[t](-1*p[t])*Math.sin(m*t-Math.PI/2):0:c.negatives?rScale[t](p[t]*-k)*Math.sin(m*t-Math.PI/2):0}).attr("x2",function(e,t){return rScale[t](1*p[t])*Math.cos(m*t-Math.PI/2)}).attr("y2",function(e,t){return rScale[t](1*p[t])*Math.sin(m*t-Math.PI/2)}).attr("class","line").style("stroke",function(e,t){return c.axisColor}).style("stroke-width","2px"),w.append("text").attr("class","legend").style("font-size",`${c.axisFont}px`).style("font-weight","549").style("font-family","Open Sans").attr("text-anchor","middle").style("fill","rgb(102, 102, 102)").attr("dy","1em").attr("x",function(e,t){return rScale[t](p[t]*c.labelFactor)*Math.cos(m*t-Math.PI/2)}).attr("y",function(e,t){return rScale[t](p[t]*c.labelFactor)*Math.sin(m*t-Math.PI/2)-c.labelFine}).text(function(e){return e}).call(function(e,t){e.each(function(){for(var e,a=d3.select(this),l=a.text().split(/\s+/).reverse(),n=[],r=0,i=a.attr("y"),s=a.attr("x"),o=parseFloat(a.attr("dy")),d=a.text(null).append("tspan").attr("x",s).attr("y",i).attr("dy",o+"em");e=l.pop();)n.push(e),d.text(n.join(" ")),d.node().getComputedTextLength()>t&&(n.pop(),d.text(n.join(" ")),n=[e],d=a.append("tspan").attr("x",s).attr("y",i).attr("dy",1.4*++r+o+"em").text(e))})},c.wrapWidth),c.roundStrokes?C.selectAll(".levels").data(d3.range(1,c.levels+1).reverse()).enter().append("circle").attr("class","gridCircle").attr("r",function(e,t){return h/c.levels*e}).style("fill",function(e,t){return c.backgroundColor}).style("stroke",function(e,t){return c.axisColor}).style("fill-opacity",c.opacityCircles).style("filter","url(#glow)"):c.independent?(levels=[],C.selectAll(".axisLabel").forEach(function(e){s=e.length;var t=s/y;e.slice(0,t).forEach(function(e){set=[],r=parseInt(e.getAttribute("y")),w[0].forEach(function(e,t){tempx=r*Math.cos(m*t-3*Math.PI/2),tempy=r*Math.sin(m*t-3*Math.PI/2),set.push({x:tempx,y:tempy})}),levels.push(set)})}),levels.forEach(function(e){C.selectAll(".levels").data([e]).enter().append("polygon").attr("points",function(e){return e.map(function(e){return[e.x,e.y].join(",")}).join(" ")}).attr("class","gridCircle").style("fill",function(e,t){return c.backgroundColor}).style("stroke",function(e,t){return c.axisColor}).style("fill-opacity",c.opacityCircles).style("filter","url(#glow)")})):(levels=[],C.selectAll(".axisLabel").forEach(function(e){s=e.length,e.forEach(function(e){set=[],r=parseInt(e.getAttribute("y")),w[0].forEach(function(e,t){tempx=r*Math.cos(m*t-3*Math.PI/2),tempy=r*Math.sin(m*t-3*Math.PI/2),set.push({x:tempx,y:tempy})}),levels.push(set)})}),levels.forEach(function(e){C.selectAll(".levels").data([e]).enter().append("polygon").attr("points",function(e){return e.map(function(e){return[e.x,e.y].join(",")}).join(" ")}).attr("class","gridCircle").style("fill",function(e,t){return c.backgroundColor}).style("stroke",function(e,t){return c.axisColor}).style("fill-opacity",c.opacityCircles).style("filter","url(#glow)")}));var P=d3.svg.line.radial().interpolate("linear-closed").radius(function(e,t){return rScale[t](c.negatives?e.value<0?.8*e.value:e.value:e.value<0?0:e.value)}).angle(function(e,t){return t*m});c.roundStrokes&&P.interpolate("cardinal-closed");var D=M.selectAll(".radarWrapper").data(t).enter().append("g").attr("class","radarWrapper").attr("id",function(e,t){return"v"+l[t].label.replace(/[^A-Z0-9]+/gi,"")});D.append("path").attr("class","radarArea").attr("id",function(e,t){return"v"+l[t].label.replace(/[^A-Z0-9]+/gi,"")}).attr("d",function(e,t){return P(e)}).style("fill",function(e,t){return c.color(t)}).style("fill-opacity",c.opacityArea).on("mouseover",function(e,t){d3.selectAll(".radarArea").transition().duration(200).style("fill-opacity",.1),d3.select(this).transition().duration(200).style("fill-opacity",.7)}).on("mouseout",function(){d3.selectAll(".radarArea").transition().duration(200).style("fill-opacity",c.opacityArea)}),D.append("path").attr("class","radarStroke").attr("d",function(e,t){return P(e)}).style("stroke-width",c.strokeWidth+"px").style("stroke",function(e,t){return c.color(t)}).style("fill","none").style("filter","url(#glow)"),D.selectAll(".radarCircle").data(function(e,t){return e}).enter().append("circle").attr("class","radarCircle").attr("r",c.dotRadius).attr("cx",function(e,t){return rScale[t](c.negatives?e.value<0?e.value*c.negativeR:e.value:e.value<0?0:e.value)*Math.cos(m*t-Math.PI/2)}).attr("cy",function(e,t){return rScale[t](c.negatives?e.value<0?e.value*c.negativeR:e.value:e.value<0?0:e.value)*Math.sin(m*t-Math.PI/2)}).style("fill",function(e,t,a){return c.color(a)}).style("fill-opacity",1),M.selectAll(".radarCircleWrapper").data(t).enter().append("g").attr("class","radarCircleWrapper").attr("child_id",function(e,t){return"v"+l[t].label.replace(/[^A-Z0-9]+/gi,"")}).selectAll(".radarInvisibleCircle").data(function(e,t){return e}).enter().append("circle").attr("class","radarInvisibleCircle").attr("series_id",function(e,t){return this.parentNode.getAttribute("child_id")}).attr("r",3*c.dotRadius).attr("cx",function(e,t){return rScale[t](c.negatives?e.value<0?e.value*c.negativeR:e.value:e.value<0?0:e.value)*Math.cos(m*t-Math.PI/2)}).attr("cy",function(e,t){return rScale[t](c.negatives?e.value<0?e.value*c.negativeR:e.value:e.value<0?0:e.value)*Math.sin(m*t-Math.PI/2)}).style("fill","none").style("pointer-events","all").on("mouseover",function(e,t){newX=parseFloat(d3.select(this).attr("cx"))-10,newY=parseFloat(d3.select(this).attr("cy"))-10,d3.selectAll(".radarArea").transition().duration(200).style("fill-opacity",.1),d3.select(".radarArea#"+this.parentNode.getAttribute("child_id")).transition().duration(200).style("fill-opacity",.7);var a={value:e.rendered};E.attr("x",newX).attr("y",newY).text(LookerCharts.Utils.textForCell(a)).transition().duration(200).style("font-family","Open Sans").style("pointer-events","none").style("opacity",1)}).on("click",function(e,t){LookerCharts.Utils.openDrillMenu({links:e.links,event:event})}).on("mouseout",function(){E.transition().duration(200).style("opacity",0),d3.selectAll(".radarArea").transition().duration(200).style("fill-opacity",c.opacityArea)});var E=M.append("text").attr("class","tooltip").style("opacity",0),I=document.createElement("style");I.type="text/css",I.innerHTML="g.radarWrapper.hidden { opacity: 0.0; } .legendCells .hidden { opacity: 0.2;text-align:center }";var $=d3.scale.ordinal().domain(l.map(e=>e.label)).range(l.map((e,t)=>c.color(t)));b=d3.select("svg");"left"===c.legendSide?(legx=20,legy=10,leg_orient="vertical",leg_pad=c.legendPad+0):"right"===c.legendSide?(legx=1.25*c.w,legy=20,leg_orient="vertical",leg_pad=c.legendPad+0):"center"===c.legendSide?(legy=window.innerHeight-60,leg_orient="horizontal",leg_pad=c.legendPad+50):"none"===c.legendSide&&(legx=-100,legy=-150,leg_orient="vertical",leg_pad=c.legendPad+70),b.append("g").attr("class","legendOrdinal").style("font-size",`${c.legendFont}px`).style("font-family","Open Sans").style("fill","rgb(102, 102, 102)");var F=d3.legend.color().shape("path",d3.svg.symbol().type("circle").size(120)()).shapePadding(leg_pad).scale($).orient(leg_orient).on("cellclick",function(e){var t;e=e.replace(/[^A-Z0-9]+/gi,"");t=e,d3.selectAll(`#v${t}`).classed("hidden",function(){return!d3.select(this).classed("hidden")});const a=d3.select(this);a.classed("hidden",!a.classed("hidden")),series_sel=d3.select(`#v${e}`)[0][0].classList.contains("hidden"),series_sel?(d3.select(`#v${e}`).style("opacity","0").style("pointer-events","none"),d3.selectAll(`[child_id=v${e}]`).style("pointer-events","none"),d3.selectAll(`[series_id=v${e}]`).style("pointer-events","none")):(d3.select(`#v${e}`).style("opacity","1").style("pointer-events",null),d3.selectAll(`[child_id=v${e}]`).style("pointer-events","all"),d3.selectAll(`[series_id=v${e}]`).style("pointer-events","all")),legend_tru=a[0][0].classList.contains("hidden"),legend_tru?d3.select(this).style("opacity",".2"):d3.select(this).style("opacity","1")});b.select(".legendOrdinal").call(F),"center"==c.legendSide?(wid=window.innerWidth/2-d3.select(".legendCells").node().getBBox().width/2+c.margin.left,d3.select(".legendOrdinal").attr("transform",function(e){return`translate(${wid},${legy})`})):"right"==c.legendSide?(wid=window.innerWidth-1.25*d3.select(".legendCells").node().getBBox().width,d3.select(".legendOrdinal").attr("transform",function(e){return`translate(${wid},${legy})`})):d3.select(".legendOrdinal").attr("transform",function(e){return`translate(${legx},${legy})`}),d()}const baseOptions={levels:{type:"number",label:"Levels",default:4,section:"Plot"},label_factor:{type:"number",label:"Axis Label Padding",default:85,section:"Plot - Advanced",display:"range",order:4},label_fine:{type:"number",label:"Axis Label Positioning",default:15,section:"Plot - Advanced",display:"range",order:5},levels:{type:"number",label:"Plot Levels",default:3,section:"Plot"},domain_max:{type:"number",label:"Axis Max Override",section:"Plot"},rounded_strokes:{type:"string",label:"Rounded Strokes?",display:"select",values:[{true:!0},{false:!1}],default:!0,section:"Plot"},independent:{type:"string",label:"Normalize Axes?",display:"select",values:[{true:!0},{false:!1}],default:!1,section:"Plot"},labelScale:{type:"string",label:"Label Scale?",display:"select",values:[{true:!0},{false:!1}],default:!0,section:"Plot"},negatives:{type:"string",label:"Allow Negatives?",display:"select",values:[{true:!0},{false:!1}],default:!1,section:"Plot"},wrap_width:{type:"number",label:"Axis Label Wrapping",default:100,section:"Plot - Advanced",order:6},opacity_area:{type:"number",label:"Area Darkness",display:"range",default:15,section:"Series",order:0},dot_radius:{type:"number",label:"Point Radius",default:30,display:"range",section:"Series",order:1},opacity_circles:{type:"number",label:"Background Darkness",display:"range",default:15,section:"Plot - Advanced",order:2},backgroundColor:{type:"string",label:"Background Color",display:"color",section:"Plot - Advanced",default:"#CDCDCD",order:1},axisColor:{type:"string",label:"Axis Color",display:"color",section:"Plot - Advanced",default:"#CDCDCD",order:0},stroke_width:{type:"number",label:"Stroke Width",default:15,display:"range",section:"Series",order:2},glow:{type:"number",label:"Glow Range",default:2,display:"range",section:"Plot - Advanced"},axis_label_font:{type:"number",label:"Axis Label Font Size (px)",default:12,section:"Plot - Advanced"},axis_scale_font:{type:"number",label:"Scale Font Size (px)",default:12,section:"Plot - Advanced"},legend_font:{type:"number",label:"Legend Font Size (px)",default:12,section:"Plot - Advanced"},legend_padding:{type:"number",label:"Legend Item Padding",default:20,display:"range",section:"Plot - Advanced"},legend_side:{type:"string",label:"Legend",display:"select",values:[{none:"none"},{left:"left"},{right:"right"},{center:"center"}],default:"left",section:"Plot"}};let baseConfig={};const visObject={create:function(e,t){e.innerHTML=""},updateAsync:function(e,t,a,l,n,r){const i=function(e,t){let a=parseInt(e,16)+t,l=a>255?255:a;return l=l.toString(16).length>1?l.toString(16):`0${l.toString(16)}`},s=(e,t)=>(e=e.indexOf("#")>=0?e.substring(1,e.length):e,t=parseInt(255*t/100),`#${i(e.substring(0,2),t)}${i(e.substring(2,4),t)}${i(e.substring(4,6),t)}`);var o={top:20,right:20,bottom:20,left:20},d=t.clientWidth,c=t.clientHeight;t.innerHTML="";var u=d3.select("#vis").append("svg").attr("width",d).attr("height",c).append("g").attr("transform","translate("+o.left+","+o.top+")"),p=["#4A80BC","#615894","#F0C733","#D13452","#E48522","#B977A9","#7bc739","#92b3d7","#e38597"];if(l.pivots){if(series=[],!(l.fields.measure_like.length%2)&&a.negatives)return void this.addError({title:"Can't display negatives with symmetric axes.",message:"Negatives can only be plotted on odd number of axes."});if(l.fields.measure_like.length<3)return void this.addError({title:"Multiple measures only.",message:"This chart requires at least 3 measures."});if(l.fields.dimensions.length>0)return void this.addError({title:"Single dimension only.",message:"This chart accepts only 1, pivoted or unpivoted dimension."});l.pivots.forEach(function(e){series.push(e.key)}),originalData=e,axes=[],l.fields.measure_like.forEach(function(e){axes.push({name:e.name,label:e.label_short})}),formattedData=[],moreData=[],series.forEach(function(t,a){values=[],axes.forEach(function(a){values.push({axis:a.label,name:a.name,value:e[0][a.name][t].value,rendered:e[0][a.name][t].rendered?e[0][a.name][t].rendered:e[0][a.name][t].value,links:e[0][a.name][t].links})}),set=[],values.forEach(function(e){set.push(e)}),moreData.push({label:t,data:set,color:a<9?p[a]:s("#D13452",1.7*a)}),formattedData.push(set)})}else{if(series=[],!(l.fields.measure_like.length%2)&&a.negatives)return console.log("troof"),void this.addError({title:"Can't display negatives with symmetric axes.",message:"Negatives can only be plotted on odd number of axes."});if(l.fields.measure_like.length<3)return void this.addError({title:"Multiple measures only.",message:"This chart requires at least 3 measures."});if(l.fields.dimension_like.length>1)return void this.addError({title:"Single dimension only.",message:"This chart accepts only 1, pivoted or unpivoted dimension."});originalData=e,qrn=l.fields.dimensions[0].name,axes=[],l.fields.measure_like.forEach(function(e){axes.push({name:e.name,label:e.label_short?e.label_short:e.label})}),formattedData=[],moreData=[],e.forEach(function(e,t){values=[],axes.forEach(function(t){values.push({axis:t.label,name:t.name,value:e[t.name].value,rendered:e[t.name].rendered?e[t.name].rendered:e[t.name].value,links:e[t.name].links})}),set=[],values.forEach(function(e){set.push(e)}),moreData.push({label:String(e[qrn].value),data:set,color:t<9?p[t]:s("#D13452",1.7*t)}),formattedData.push(set)}),series=moreData.map(e=>e.label)}opt=Object.assign({},baseOptions),moreData.forEach(function(e,t){opt[`${e.label}_color`]={type:"string",label:`${e.label} - Color`,display:"color",section:"Series",default:`${e.color}`}}),this.trigger("registerOptions",opt);var f=d3.scale.ordinal().range(Object.keys(a).filter(function(e){return-1!==e.indexOf("_color")}).map(function(e){return a[e]})),g={w:d,h:c,margin:o,maxValue:.5,levels:a.levels,roundStrokes:a.rounded_strokes,color:f,axisFont:a.axis_label_font,scaleFont:a.axis_scale_font,labelFactor:1.5*a.label_factor/100,labelFine:1.2*a.label_fine,wrapWidth:a.wrap_width,opacityArea:a.opacity_area/100,dotRadius:a.dot_radius/5,opacityCircles:a.opacity_circles/200,backgroundColor:a.backgroundColor,axisColor:a.axis_color,strokeWidth:a.stroke_width/5,legendSide:a.legend_side,glow:a.glow/20,negatives:a.negatives,axisColor:a.axisColor,negativeR:a.negative_r,independent:a.independent,legendPad:a.legend_padding,legendFont:a.legend_font,domainMax:a.domain_max,labelScale:a.labelScale};u.append("g").call(RadarChart("#vis",formattedData,g,moreData,[],originalData,axes,r))}};looker.plugins.visualizations.add(visObject);
2 |
--------------------------------------------------------------------------------
/docs/api_reference.md:
--------------------------------------------------------------------------------
1 | # API 2.0 Reference
2 |
3 | The entry point into the Visualization API is a call to the `looker.plugins.visualizations.add` function. This function accepts a _visualization object_, which fully defines your custom visualization.
4 |
5 | Here's a dead simple (and very boring) visualization:
6 |
7 | ```js
8 | looker.plugins.visualizations.add({
9 | create: function(element, config){
10 | element.innerHTML = "Ready to render!
";
11 | },
12 | updateAsync: function(data, element, config, queryResponse, details, doneRendering){
13 | var html = "";
14 | for(var row of data) {
15 | var cell = row[queryResponse.fields.dimensions[0].name];
16 | html += LookerCharts.Utils.htmlForCell(cell);
17 | }
18 | element.innerHTML = html;
19 | doneRendering()
20 | }
21 | });
22 | ```
23 |
24 |
25 | ### Environment
26 |
27 | Since the Visualization API is plain JavaScript, you can use any JavaScript libraries to visualize your data.
28 |
29 | Your JavaScript code will run in a sandboxed iframe, separate from your Looker instance. All the same, authors of custom visualizations are responsible for ensuring that the code they write is secure.
30 |
31 | Just like all web development, supporting different web browsers (Chrome, Edge, Firefox) can occasionally be an issue. The Visualization API works on all browsers that Looker supports, but it's up to you to ensure browser support for your custom code. For example, uses of ES6 such as `let` or `()=>` will work in most browsers, but may cause failures in IE11 or rendering PDFs via PhantomJS.
32 |
33 | ### Installation
34 |
35 | Custom visualizations are installed by defining a manifest via the Admin > Platform > Visualizations form. A manifest consists of a unique string id, a human readable label, the URI of your visualization, and links to any dependencies that you require (ex: d3, jQuery, underscore).
36 |
37 | Custom Visualizations should be served as https content externally to looker. If you are migrating from the first version of the custom visualization API, you may have files in your `looker/plugins/visualizations` directory. This location is still accessible and a relative path to your visualization can be supplied, but it is recommended that you migrate your content to a different location. Since the /plugins directory is not in the Shared File System, clustered environments will not have access to your visualization files on all nodes.
38 |
39 | ## The Visualization Object
40 |
41 | ##### Required Properties
42 |
43 | - `create` _function_
44 |
45 | A function called once, when your visualization is first drawn on the page. This function is expected to set up anything that you'd like your visualization to always have available. You could load a library, or create a set of elements and controls you'll use later.
46 |
47 | [See details about the `create` function →](#the-create-function)
48 |
49 | - `updateAsync` / `update` _function_
50 |
51 | A function that is called every time the state of your visualization may need to change. It will always be called after `create`, and may be called many times, so it should be as fast as possible.
52 |
53 | This function can be called for many reasons, but usually it's when the query to visualize changes, a configuration option was changed, or the visualization was resized.
54 |
55 | There is a synchronous and asynchronous version of this function – you'll only need to specify one. (It's an error to define both.) The async method is more reliable for pdf rendering due to the nature of iframes.
56 |
57 | [See details about these `update` functions →](#the-updateasync-and-update-functions)
58 |
59 | #### Optional Properties
60 |
61 | - `options` _object_
62 |
63 | An object detailing options that users can set on your visualization.
64 |
65 | [See details about exposing a configuration UI →](#presenting-configuration-ui)
66 |
67 | - `destroy` _function_ *Not yet implemented in API V2*
68 |
69 | A function that is called just before the visualization is removed from the page. This can be used to clean up any event listeners or other state. It may never be called, for example, if the user closes the window.
70 |
71 | #### Added Properties
72 |
73 | These properties are added to your object automatically by Looker after the visualization is passed to `looker.plugins.visualizations.add`. You can reference them via `this` within the context of the `create`, `update`, `updateAsync`, and `destroy` functions.
74 |
75 | - `addError` _function_
76 |
77 | A function that your visualization code may call to tell the UI to display an error message instead of the visualization. It takes an error object.
78 |
79 | Once an error is added it will remain visible until `clearErrors` is called.
80 |
81 | **Example:**
82 |
83 | ```js
84 | this.addError({
85 | title: "Two Dimensions Required",
86 | message: "This really great visualization requires two dimensions."
87 | });
88 | ```
89 |
90 | - `clearErrors` _function_
91 |
92 | A function that your visualization code may call to clear any errors that have been added via `addError`.
93 |
94 | **Example:**
95 |
96 | ```js
97 | this.clearErrors();
98 | ```
99 |
100 | - `trigger` _function_
101 |
102 | A function that can be called to trigger an event outside the visualization.
103 |
104 | [See details about events →](#events)
105 |
106 | **Example:**
107 |
108 | ```js
109 | this.trigger("limit", [20]);
110 | ```
111 |
112 | ## The `create` function
113 |
114 | The `create` function will be passed two parameters, `element` and `config`.
115 |
116 | ```js
117 | create: function(element, config){
118 | // Your update code here.
119 | },
120 | ```
121 |
122 | ### Parameters
123 |
124 | - `element` _DOMElement_
125 |
126 | A DOM Element representing a container to render your visualization into.
127 |
128 | - `config` _object_
129 |
130 | An object representing the values of any configuration [options](#presenting-configuration-ui) that the user has set for this chart.
131 |
132 | **Example**: `{my_configuration_option: "User Value"}`
133 |
134 | ### Example
135 |
136 |
137 | ## The `updateAsync` and `update` functions
138 |
139 | The preferred method of updating your visualization is the `updateAsync` method. This method allows your visualization to perform an asynchronous action, such as loading a file or sending a web request.
140 |
141 | This version of the function has an additional parameter, which is a callback to be called when rendering is complete:
142 |
143 | ```js
144 | updateAsync: function(data, element, config, queryResponse, details, done){
145 | // An example of an asynchronous update, fetching a file.
146 | d3.request("https://example.com/fun-file.docx").response(function(xhr) {
147 | // Your update code here that uses this file.
148 | done();
149 | });
150 | }
151 | ```
152 |
153 | Properly letting Looker know when the visualization is done rendering lets Looker optimize PDF rendering and ensures images of visualizations can be properly captured.
154 |
155 | Some older visualizations use the `update` function. This still works but is not the preferred method, since Looker cannot know when a visualization is fully rendered. It will be passed five parameters:
156 |
157 | ```js
158 | update: function(data, element, config, queryResponse, details){
159 | // Your update code here.
160 | }
161 | ```
162 |
163 |
164 | ### Parameters
165 |
166 | - `data` _array_
167 |
168 | An array of rows representing the current data in the query. May be `null`.
169 |
170 | Each row is an object with the keys representing field names, and the value representing a "cell" object. [Here's details on dealing with the cell object](#rendering-data).
171 |
172 | - `element` _DOMElement_
173 |
174 | A DOM Element representing a container to render your visualization into.
175 |
176 | - `config` _object_
177 |
178 | An object representing the values of any configuration [options](#presenting-configuration-ui) that the user has set for this chart.
179 |
180 | **Example**: `{my_configuration_option: "User Value"}`
181 |
182 | - `queryResponse` _object_
183 |
184 | An object representing metadata about the current query, such as meatadata about fields. May be `null`.
185 |
186 | - `details` _object_
187 |
188 | Details about the current rendering context. Contains information about why the chart is rendering and what has changed. Usually this information is only used in advanced cases.
189 |
190 | Pertaining to [crossfilters](https://docs.looker.com/dashboards/cross-filtering) in Looker dashboards that use the new dashboard experience, allowed attributes include `crossfilterEnabled` and `crossfilters`.
191 |
192 | The `print` attribute can be used in conjunction with the `done` function to improve PDF rendering for custom visualizations, especially those that include animation. For example:
193 |
194 | ```js
195 | if (details.print) {
196 | done();
197 | }
198 | ```
199 |
200 | - `done` _function_
201 |
202 | A callback to indicate that the visualization is fully rendered. This is especially important to call if your visualization needs to perform asynchronous calls or needs to be rendered as a PDF.
203 |
204 |
205 | ## Rendering Data
206 |
207 | Looker has a rich set of data formatting tools in LookML that can be used to customize the appearance and behavior of data points. This includes field-level LookML settings like custom value formats, HTML, drill links, and data actions.
208 |
209 | Cells have a `value` property, which is the only property guaranteed to exist in all cases. This property is a native JavaScript type that matches the type of the field the cell belongs to. However, it's not safe to insert directly into HTML or SVG, as no HTML escaping has been performed, and some field values are complex JavaScript objects or arrays that will render in undesirable or confusing ways.
210 |
211 | To ensure that all visualizations render these items consistently and safely, there are a number of utility functions for turning the cell metadata passed to your chart in `data` into different representations for different purposes. The cell metadata may compress or omit certain fields in some cases, and these helper functions will let you provide a consistent experience.
212 |
213 | These are all available on the global `LookerCharts.Utils` object.
214 |
215 |
216 | - `LookerCharts.Utils.textForCell(cell)`
217 |
218 | This function accepts a cell and returns a string representation of it suitable for display. It will always be a string – this function never returns `null` or any other type.
219 |
220 | - `LookerCharts.Utils.htmlForCell(cell)`
221 |
222 | This function accepts a cell and returns a string containing HTML. It will always be a string – this function never returns `null` or any other type.
223 |
224 | The output of this function is properly escaped and suitable for directly inserting into your element's HTML. The output HTML will also correctly display the drill menu on click when appropriate for the data.
225 |
226 | - `LookerCharts.Utils.filterableValueForCell(cell)`
227 |
228 | This function accepts a cell and returns a Looker advanced filter syntax string that would match the value of this cell.
229 |
230 | - `LookerCharts.Utils.toggleCrossfilter({row, pivot, event})`
231 |
232 | This function accepts a row, pivot, or event and is used to check if crossfiltering is enabled for a visualization. For example, to add an event listener to an element that checks if crossfiltering is enabled when the event occurs:
233 |
234 | ```js
235 | d3.select(#myElement)
236 | .on("click", function (d) {
237 | if (details.crossfilterEnabled) {
238 | LookerCharts.Utils.toggleCrossfilter({
239 | row: d.row,
240 | event: d3.event,
241 | });
242 | } else {
243 | ...
244 | ```
245 |
246 | - `LookerCharts.Utils.getCrossfilterSelection(row, pivot?)`
247 |
248 | This function accepts a row or pivot and is used to check if that row or pivot is currently selected in a custom visualization. The function returns an enum for `CrossfilterSelection {NONE, SELECTED, UNSELECTED}` of 0 if `NONE`, 1 if `SELECTED`, and 2 if `UNSELECTED`.
249 |
250 | For example, to conditionally apply a fill color to a row if it is being crossfiltered and apply default colors if crossfiltering is not enabled for that row:
251 |
252 | ```js
253 | d3.select("#myElement")
254 | .attr("fill", function (d) {
255 | const crossfilter = LookerCharts.Utils.getCrossfilterSelection(d.row)
256 | if (details.crossfilterEnabled && crossfilter === 1) {
257 | return d.color
258 | } else {
259 | return "#DEE1E5" //Hex used for unselected elements in native visualizations
260 | ...
261 | ```
262 |
263 | - `LookerCharts.Utils.openDrillMenu(options)`
264 |
265 | The output of `htmlForCell` will automatically show the drill menu if needed, but that may not be appropriate for certain types of rendering. (For example, SVG cannot render HTML but you may want to capture a click event to begin a drill.)
266 |
267 | The `options` object has the following parameters:
268 |
269 | - `links` **Required** _array_
270 |
271 | An array of the objects returned from the `links` property of a cell. If you want to display links for multiple cells at once, you may concatenate these arrays together first. For custom links, provide an array of objects with the following form: { label: _string_, type: 'drill', type_label: _string_, url: _url_ }
272 |
273 | - `event` _HTML DOM Event_
274 |
275 | The click (or other) event that caused the drill event. Looker will use this to determine where to place the drill menu. If you don't have one, try passing an object with the pageX and pageY coordinates.
276 |
277 | **Example:**
278 |
279 | ```js
280 | for (var row in data) {
281 | foreach(var row in data) {
282 | var cell = data[queryResponse.fields.dimensions[0].name];
283 | var cellElement = myBuildElementFunction(cell);
284 | cellElement.onclick = function(event) {
285 | LookerCharts.Utils.openDrillMenu({
286 | links: cell.links,
287 | event: event
288 | });
289 | };
290 | // ... more visualization stuff...
291 | }
292 | }
293 | ```
294 |
295 | ## Presenting Configuration UI
296 |
297 | The `options` parameter is an object where the keys are an arbitrary identifier for an option name, and the value is an object describing information about the option.
298 |
299 | Here's an example:
300 |
301 | ```js
302 | options: {
303 | color_range: {
304 | type: "array",
305 | label: "Color Range",
306 | display: "colors"
307 | },
308 | top_label: {
309 | type: "string",
310 | label: "Label (for top)",
311 | placeholder: "My Great Chart"
312 | },
313 | transport_mode: {
314 | type: "string",
315 | label: "Mode of Transport",
316 | display: "select",
317 | values: [
318 | {"Airplane": "airplane"},
319 | {"Car": "car"},
320 | {"Unicycle": "unicycle"}
321 | ],
322 | default: "unicycle"
323 | }
324 | }
325 | ```
326 |
327 | ### Option Object Parameters
328 |
329 | ##### Basic Parameters
330 |
331 | - `type` _string_
332 |
333 | The data type of the option.
334 |
335 | **Allowed Values:** `string` (default), `number`, `boolean`, `array`
336 |
337 | - `label` _string_
338 |
339 | The human-readable label of the option that will be displayed to the user.
340 |
341 | - `default`
342 |
343 | The default value of the option. When unspecified, the value of the option is `null`. This should be a value of the same type as `type`.
344 |
345 | - `display` _string_
346 |
347 | Certain `type`s can be presented in the UI in different ways.
348 |
349 | - when `type` is `string`:
350 |
351 | **Allowed Values:** `text` (default), `select`, `radio`
352 |
353 | - when `type` is `number`:
354 |
355 | **Allowed Values:** `number` (default), `range`
356 |
357 | - when `type` is `array`:
358 |
359 | **Allowed Values:** `text` (default), `color`, `colors`
360 |
361 | - `placeholder` _string_
362 |
363 | For `display` values that support it, an example value or explanation to give the user a hint about what to type.
364 |
365 | ##### Display-specific properties
366 |
367 | - `values` _array_ of _objects_
368 |
369 | When `display` is `radio` or `select`, an array containing labels and values that will be listed in the interface.
370 |
371 | Each item in the array should be an object with a single key-value pair, representing the label and the value of the option. The label will only be presented in the UI, at render time you'll only receive the value.
372 |
373 | **Example:**
374 |
375 | ```js
376 | values: [
377 | {"Center": "c"},
378 | {"Left": "l"},
379 | {"Right": "r"}
380 | ]
381 | ```
382 |
383 | If `display` is `radio`, each option can additionally be given a `description`:
384 |
385 | ```js
386 | values: [
387 | {"Cool": "c"},
388 | {"Uncool": {
389 | value: "unc",
390 | description: "Only choose this if the data is very uncool."
391 | }
392 | }
393 | ]
394 | ```
395 |
396 | - `max` _number_
397 |
398 | When `display` is `range`, the maximum number allowed to be selected by the range slider.
399 |
400 | - `min` _number_
401 |
402 | When `display` is `range`, the minimum number allowed to be selected by the range slider.
403 |
404 | - `step` _number_
405 |
406 | When `display` is `range`, the amount each tick of the range slider represents.
407 |
408 | ##### Organizing options
409 |
410 | - `section` _string_
411 |
412 | For charts with many options, a label for which section an option should appear in. The UI will group the options by their `section` values. If there is only one section, the section UI will not be shown.
413 |
414 | If you're using `section` then it should be set on every option.
415 |
416 | - `order` _number_
417 |
418 | A number representing the order of options for presentation in the UI. If specified, the options will be sorted according to this order.
419 |
420 | - `display_size` _string_
421 |
422 | A size class representing the width of the option in the UI. For example, if you wanted to show a "Minimum" and a "Maximum" option next to each other, you could set each of their `display_size`s to `half`.
423 |
424 | **Allowed Values:** `normal` (default), `half`, `third`
425 |
426 | ## Events
427 |
428 | Events can be triggered by the chart (usually in response to user interaction) that can update properties of the visualization or query.
429 |
430 | They can be triggered by calling the `trigger` function on the visualization object. The first parameter is always the _event name_ and the second parameter is an array of _arguments_.
431 |
432 | ```js
433 | this.trigger("limit", [20]);
434 | ```
435 |
436 | #### Available Events
437 |
438 | - `updateConfig`
439 |
440 | Update the current configuration settings of the chart.
441 |
442 | **Argument:** an object containing config keys to update. Unspecified keys are not changed.
443 |
444 | **Example:**
445 |
446 | ```js
447 | var vis = this;
448 | $(element).find(".axis").click(function(){
449 | vis.trigger("updateConfig", [{axis_hidden: true}]
450 | });
451 | ```
452 |
453 | - `limit`
454 |
455 | Update the limit of the underlying query:
456 |
457 | **Argument:** an integer representing a new limit for the query.
458 |
459 | ```js
460 | var vis = this;
461 | $(element).find(".show-all").click(function(){
462 | vis.trigger("limit", [500]);
463 | });
464 | ```
465 |
466 | - `filter`
467 |
468 | Update the value of a filter on the underlying query:
469 |
470 | ```js
471 | var vis = this;
472 | $(element).find(".show-all-tommys").click(function(){
473 | vis.trigger("filter", [{
474 | field: "users.name", // the name of the field to filter
475 | value: "%tommy%", // the "advanced syntax" for the filter
476 | run: true, // whether to re-run the query with the new filter
477 | }]);
478 | });
479 | ```
480 |
481 | - `loadingStart`
482 |
483 | Mark the visualization as loading. Most of the time this isn't neccessary, but if your visualization loads an object from a remote location or performs a long calculation you can use this to continue to display the loading indicator.
484 |
485 | It will appear loading until `loadingEnd` is triggered.
486 |
487 | - `loadingEnd`
488 |
489 | Mark the visualization as no longer loading.
490 |
491 | - `registerOptions` (Looker 5.24+)
492 |
493 | Allows visualizations to register additional options after the visualization has been registered:
494 |
495 | ```
496 | update: function(data, element, config, queryResponse, details){
497 | options = {}
498 | // Create an option for each measure in your query
499 | queryResponse.fields.measure_like.forEach(function(field) {
500 | id = "color_" + field.name
501 | options[id] =
502 | {
503 | label: field.label_short + " Color",
504 | default: "#8B7DA8",
505 | section: "Style",
506 | type: "string",
507 | display: "color"
508 | }
509 | })
510 | this.trigger('registerOptions', options) // register options with parent page to update visConfig
511 | ...
512 | }
513 | });
514 | ```
515 |
--------------------------------------------------------------------------------
/docs/custom_color_palette.md:
--------------------------------------------------------------------------------
1 | in the 'options' create two color ranges (allowing for custom input)
2 |
3 | ...
4 | colorPreSet:
5 | {
6 | type: 'string',
7 | display: 'select',
8 | label: 'Color Range',
9 | section: 'Data',
10 | values: [{'Custom': 'c'},
11 | {'Tomato to Steel Blue': '#F16358,#DF645F,#CD6566,#BB666D,#A96774,#97687B,#856982,#736A89,#616B90,#4F6C97,#3D6D9E'},
12 | {'Pink to Black': '#170108, #300211, #49031A, #620423, #79052B, #910734, #AA083D, #C30946, #DA0A4E, #F30B57, #F52368, #F63378, #F63C79, #F75389, #F86C9A, #F985AB, #FB9DBC, #FCB4CC, #FDCDDD, #FEE6EE'},
13 | {'Green to Red': '#7FCDAE, #7ED09C, #7DD389, #85D67C, #9AD97B, #B1DB7A, #CADF79, #E2DF78, #E5C877, #E7AF75, #EB9474, #EE7772'},
14 | {'White to Green': '#ffffe5,#f7fcb9 ,#d9f0a3,#addd8e,#78c679,#41ab5d,#238443,#006837,#004529'}],
15 | default: 'c',
16 | order: 1
17 | },
18 | colorRange: {
19 | type: 'array',
20 | label: 'Custom Color Ranges',
21 | section: 'Data',
22 | order: 2,
23 | placeholder: '#fff, red, etc...'
24 | },
25 | ...
26 | In the *update* function:
27 |
28 | ...
29 | if (settings.colorPreSet == 'c') {
30 | var colorSettings = settings.colorRange || ['white','green','red']; // put a default in
31 | } else {
32 | var colorSettings = settings.colorPreSet.split(",");
33 | };
34 | ...
35 |
--------------------------------------------------------------------------------
/docs/getting_started.md:
--------------------------------------------------------------------------------
1 |
2 | The Looker Visualization API is a pure-JavaScript API that runs in a [sandboxed iframe](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox) and is hosted within the Looker application.
3 |
4 | The same visualization code can provide a visualization anywhere in Looker: Explores, Looks, dashboards, embeds, or in PDF or rendered images.
5 |
6 | Each visualization represents a view of a single Looker query. Looker handles running the query, and passes it to your visualization code. You'll also get passed a DOM element that your visualization code can draw into.
7 |
8 | ### Requirements
9 |
10 | - Some knowledge of JavaScript and web development is necessary.
11 | - Looker Admin access is required to create and update manifests, but otherwise is not required.
12 |
13 | ## Hello World
14 |
15 | Let's walk thorough creating a simple visualization script.
16 |
17 | > For more details on each parameter consult the [Visualization API Reference](api_reference.md).
18 |
19 | We'll create a simple "Hello World" visualization that displays the first dimension of a given query. The final result should look like this:
20 |
21 | 
22 |
23 | ### Setup
24 |
25 | To develop and test a visualization in Looker, you have two options:
26 |
27 | 1. [Save the visualization's source code directly in your LookML project](https://docs.looker.com/reference/manifest-params/visualization) (recommended)
28 | 2. [Host your visualization over https and point to it from Looker](https://docs.looker.com/admin-options/platform/visualizations)
29 |
30 | Once you've set up one of those options, you can follow the example below to create `hello_world.js`.
31 |
32 | ### Just The Bones
33 |
34 | If you want to jump to the final source code for this example, [that's available here](../src/examples/hello_world/hello_world.js).
35 |
36 | In the folder you are hosting via `pyhttps` open a new blank JavaScript file on your computer and call it `hello_world.js`.
37 |
38 | You register a new custom visualization with Looker by calling the `looker.plugins.visualizations.add` function and passing it a visualization object. This object contains the entire definition of your visualization and its configuration UI.
39 |
40 | Here's the skeleton of our visualization with all the required properties filled out - just a `create` and `updateAsync` function where we'll write our visualization code:
41 |
42 | ```js
43 | looker.plugins.visualizations.add({
44 | create: function(element, config) {
45 |
46 | },
47 | updateAsync: function(data, element, config, queryResponse, details, done) {
48 |
49 | }
50 | })
51 | ```
52 |
53 | This is a perfectly valid Looker visualization, but it's not very visual yet. It'll just look like a blank box. But hey, it's a start.
54 |
55 | ### Setting The Stage
56 |
57 | Let's look at the `create` function now. Note it has two arguments: `element` and `config`. We'll just worry about the first one for now.
58 |
59 | Looker gives us an `element`, which is the [DOM Element](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement) that Looker would like us to put our visualization into. Looker will build this element for you and make it the proper size, you just need to put stuff in there.
60 |
61 | The `create` function gives us the opportunity to do the initial setup of our element. For our example, we want to create a chart that will look kind of like this HTML:
62 |
63 | ```html
64 |
67 |
70 | ```
71 |
72 | Using JavaScript, we can start to insert stuff into our DOM Element to match the structure we're looking for above.
73 |
74 | Here's what that looks like fleshed out:
75 |
76 | ```js
77 | create: function(element, config) {
78 |
79 | // Insert a
91 | `;
92 |
93 | // Create a container element to let us center the text.
94 | var container = element.appendChild(document.createElement("div"));
95 | container.className = "hello-world-vis";
96 |
97 | // Create an element to contain the text.
98 | this._textElement = container.appendChild(document.createElement("div"));
99 |
100 | },
101 | ```
102 |
103 | So we've now got some CSS in there, and we've made our `hello-world-vis` container, and a div inside there.
104 |
105 | We've also assigned `this._textElement` to the container we want to render into. That's a convenience so we don't have to look it up again later when the chart begins to render.
106 |
107 | That's all we need to do in our `create` function – it's just a convenient place to do setup that only needs to happen once.
108 |
109 | ### Rendering
110 |
111 | It's time to visualize! We'll flesh out our `updateAsync` method now. This method gets called any time the chart is supposed to visualize changes, or when any other event happens that might affect how your chart is rendered. (For example, the chart may have been resized or a configuration option changed.)
112 |
113 | In our case, we only need to find the first dimension in the first cell. We can do so using `data`, which is an array of every row of the dataset, and `queryResponse`, which contains metadata about the query, such as field names and types.
114 |
115 | We can also use a helper method called `LookerCharts.Utils.htmlForCell` to give us the proper HTML representation of the data point in that cell, which automatically handles things like drill links, formatting, and data actions:
116 |
117 | ```js
118 | updateAsync: function(data, element, config, queryResponse, details, done) {
119 |
120 | // Grab the first cell of the data.
121 | var firstRow = data[0];
122 | var firstCell = firstRow[queryResponse.fields.dimensions[0].name];
123 |
124 | // Insert the data into the page.
125 | this._textElement.innerHTML = LookerCharts.Utils.htmlForCell(firstCell);
126 |
127 | // Always call done to indicate a visualization has finished rendering.
128 | done()
129 | }
130 | ```
131 |
132 | :tada: And we're done! That's all we need to do to have a fully-functioning custom visualization.
133 |
134 | Let's press on though, and improve the user experience a bit...
135 |
136 | ### Handling Errors
137 |
138 | Our `updateAsync` code is grabbing the first row of data, but what happens if the query returns no results or doesn't contain any dimensions?
139 |
140 | Well, currently you'll get an ugly JavaScript error in the browser console, and most users will be confused when the chart doesn't render.
141 |
142 | However, it's easy for charts to display custom error messages if they encounter problems while trying to render. Looker will add two functions to your visualization object: `addError` and `clearErrors` that can be used to display a nice error message to the user.
143 |
144 | Because they're added to the visualization object, they're available from the context of your `updateAsync` function via the `this` object.
145 |
146 | We can just modify the beginning of our `updateAsync` method to detect an error condition, let the user know there's an issue, and bail out instead of trying to render:
147 |
148 | ```js
149 | updateAsync: function(data, element, config, queryResponse, details, done) {
150 |
151 | // Clear any errors from previous updates.
152 | this.clearErrors();
153 |
154 | // Throw some errors and exit if the shape of the data isn't what this chart needs.
155 | if (queryResponse.fields.dimensions.length == 0) {
156 | this.addError({title: "No Dimensions", message: "This chart requires dimensions."});
157 | return;
158 | }
159 |
160 | // ... the rest of the update code here ...
161 | ```
162 |
163 | That's it! If the user creates a query that only has measures, they'll now see this:
164 |
165 | 
166 |
167 | ### Configuration
168 |
169 | The final step is to allow users to customize aspects of their visualization.
170 |
171 | That's really easy – in addition to the other properties of your visualization object, there's a special property called `options`.
172 |
173 | We can use that to specify what kind of options the chart needs:
174 |
175 | ```js
176 | looker.plugins.visualizations.add({
177 | options: {
178 | font_size: {
179 | type: "string",
180 | label: "Font Size",
181 | values: [
182 | {"Large": "large"},
183 | {"Small": "small"}
184 | ],
185 | display: "radio",
186 | default: "large"
187 | }
188 | },
189 | // ... rest of visualization object ...
190 | ```
191 |
192 | Here we've created a `font_size` option that will display as radio buttons, letting users choose between "small" and "large" font sizes.
193 |
194 | There are [lots of parameters available for making more complicated options available](api_reference.md#presenting-configuration-ui), but we'll just look at the basics here.
195 |
196 | Specifying the `options:` property is all you need to add the option to the visualization picker.
197 |
198 | So how do we use it?
199 |
200 | Recall that in the `updateAsync` method there's a `config` parameter that gets passed in. This will contain the currently selected options:
201 |
202 | ```js
203 | updateAsync: function(data, element, config, queryResponse, details, done) {
204 | ```
205 |
206 | That `config` object looks something like this:
207 |
208 | ```js
209 | {font_size: "large"}
210 | ```
211 |
212 | So when we're updating the chart we can easily check that and do stuff in response to it. Here's a check we can add to the end of the update method to implement the font size changes:
213 |
214 | ```js
215 | if (config.font_size == "small") {
216 | this._textElement.className = "hello-world-text-small";
217 | } else {
218 | this._textElement.className = "hello-world-text-large";
219 | }
220 | ```
221 |
222 | We should also update the `
66 | `
67 |
68 | this.tooltip = d3.select(element).append('div').attr('class', 'chord-tip')
69 | this.svg = d3.select(element).append('svg')
70 | },
71 |
72 | computeMatrix(data, dimensions, measure) {
73 | const indexByName = d3.map()
74 | const nameByIndex = d3.map()
75 | const matrix: any[] = []
76 | let n = 0
77 |
78 | // Compute a unique index for each package name.
79 | dimensions.forEach(dimension => {
80 | data.forEach(d => {
81 | const value = d[dimension].value
82 | if (!indexByName.has(value)) {
83 | nameByIndex.set(n.toString(), value)
84 | indexByName.set(value, n++)
85 | }
86 | })
87 | })
88 |
89 | // Construct a square matrix
90 | for (let i = -1; ++i < n;) {
91 | matrix[i] = []
92 | for (let t = -1; ++t < n;) {
93 | matrix[i][t] = 0
94 | }
95 | }
96 |
97 | // Fill matrix
98 | data.forEach(d => {
99 | const row = indexByName.get(d[dimensions[1]].value)
100 | const col = indexByName.get(d[dimensions[0]].value)
101 | const val = d[measure].value
102 | matrix[row][col] = val
103 | })
104 |
105 | return {
106 | matrix,
107 | indexByName,
108 | nameByIndex
109 | }
110 | },
111 |
112 | // Render in response to the data or settings changing
113 | update(data, element, config, queryResponse) {
114 | if (!handleErrors(this, queryResponse, {
115 | min_pivots: 0, max_pivots: 0,
116 | min_dimensions: 2, max_dimensions: 2,
117 | min_measures: 1, max_measures: 1
118 | })) return
119 |
120 | const dimensions = queryResponse.fields.dimension_like
121 | const measure = queryResponse.fields.measure_like[0]
122 |
123 | // Set dimensions
124 | const width = element.clientWidth
125 | const height = element.clientHeight
126 | const thickness = 15
127 | const outerRadius = Math.min(width, height) * 0.5
128 | const innerRadius = outerRadius - thickness
129 |
130 | // Stop if radius is < 0
131 | if (innerRadius < 0) return
132 |
133 | const valueFormatter = formatType(measure.value_format) || defaultFormatter
134 |
135 | const tooltip = this.tooltip
136 |
137 | // Set color scale
138 | const colorScale: d3.ScaleOrdinal = d3.scaleOrdinal()
139 | if (config.color_range == null || !(/^#/).test(config.color_range[0])) {
140 | // Workaround for Looker bug where we don't get custom colors.
141 | config.color_range = this.options.color_range.default
142 | }
143 | const color: d3.ScaleOrdinal = colorScale.range(config.color_range)
144 |
145 | // Set chord layout
146 | const chord = d3.chord()
147 | .padAngle(0.025)
148 | .sortSubgroups(d3.descending)
149 | .sortChords(d3.descending)
150 |
151 | // Create ribbon generator
152 | const ribbon = d3.ribbon()
153 | .radius(innerRadius)
154 |
155 | // Create arc generator
156 | const arc = d3.arc()
157 | .innerRadius(innerRadius)
158 | .outerRadius(outerRadius)
159 |
160 | // Turn data into matrix
161 | const matrix = this.computeMatrix(data, dimensions.map(d => d.name), measure.name)
162 |
163 | // draw
164 | const svg = this.svg
165 | .html('')
166 | .attr('width', '100%')
167 | .attr('height', '100%')
168 | .append('g')
169 | .attr('class', 'chordchart')
170 | .attr('transform', 'translate(' + width / 2 + ',' + (height / 2) + ')')
171 | .datum(chord(matrix.matrix))
172 |
173 | svg.append('circle')
174 | .attr('r', outerRadius)
175 |
176 | const ribbons = svg.append('g')
177 | .attr('class', 'ribbons')
178 | .selectAll('path')
179 | .data((chords: any) => chords)
180 | .enter().append('path')
181 | .style('opacity', 0.8)
182 | .attr('d', ribbon)
183 | .style('fill', (d: any) => color(d.target.index))
184 | .style('stroke', (d: any) => d3.rgb(color(d.index)).darker())
185 | .on('mouseenter', (d: any) => {
186 | tooltip.html(this.titleText(matrix.nameByIndex, d.source, d.target, valueFormatter))
187 | })
188 | .on('mouseleave', (d: any) => tooltip.html(''))
189 |
190 | const group = svg.append('g')
191 | .attr('class', 'groups')
192 | .selectAll('g')
193 | .data((chords: any) => chords.groups)
194 | .enter().append('g')
195 | .on('mouseover', (d: any, i: number) => {
196 | ribbons.classed('chord-fade', (p: any) => {
197 | return (
198 | p.source.index !== i
199 | && p.target.index !== i
200 | )
201 | })
202 | })
203 |
204 | const groupPath = group.append('path')
205 | .style('opacity', 0.8)
206 | .style('fill', (d: any) => color(d.index))
207 | .style('stroke', (d: any) => d3.rgb(color(d.index)).darker())
208 | .attr('id', (d: any, i: number) => `group${i}`)
209 | .attr('d', arc)
210 |
211 | const groupPathNodes = groupPath.nodes()
212 |
213 | const groupText = group.append('text').attr('dy', 11)
214 |
215 | groupText.append('textPath')
216 | .attr('xlink:href', (d: any, i: number) => `#group${i}`)
217 | .attr('startOffset', (d: any, i: number) => (groupPathNodes[i].getTotalLength() - (thickness * 2)) / 4)
218 | .style('text-anchor', 'middle')
219 | .text((d: any) => matrix.nameByIndex.get(d.index.toString()))
220 |
221 | // Remove the labels that don't fit. :(
222 | groupText
223 | .filter(function (this: SVGTextElement, d: any, i: number) {
224 | return groupPathNodes[i].getTotalLength() / 2 - 16 < this.getComputedTextLength()
225 | })
226 | .remove()
227 | },
228 |
229 | titleText: function (lookup, source, target, formatter) {
230 | const sourceName = lookup.get(source.index)
231 | const sourceValue = formatter(source.value)
232 | const targetName = lookup.get(target.index)
233 | const targetValue = formatter(target.value)
234 | return `
235 | ${sourceName} → ${targetName}: ${sourceValue}
236 | ${targetName} → ${sourceName}: ${targetValue}
237 | `
238 | }
239 | }
240 |
241 | looker.plugins.visualizations.add(vis)
242 |
--------------------------------------------------------------------------------
/src/examples/collapsible_tree/README.md:
--------------------------------------------------------------------------------
1 | # Collapsible Tree
2 |
3 | 
4 |
5 | This diagram displays a [treemap](https://en.wikipedia.org/wiki/Tree_structure), showing a hierarchy of a series of dimensions.
6 |
7 | 
8 |
9 | **Implementation Instructions**
10 | Follow the instructions in [Looker's documentation](https://docs.looker.com/admin-options/platform/visualizations). Note that this viz does not require an SRI hash and has no dependencies. Simply create a unique ID, a label for the viz, and paste in the CDN link below.
11 |
12 | **CDN Link**
13 |
14 | Paste the following URL into the "Main" section of your Admin/Visualization page.
15 |
16 | https://looker-custom-viz-a.lookercdn.com/master/collapsible_tree.js
17 |
18 | **How it works**
19 |
20 | Create a Look with two or more dimensions.
21 |
22 | For example, in the collapsible tree diagram featured above, you can see the nested relationship between department, category and brand in an ecommerce catalog.
23 |
24 | **More Info**
25 |
26 | The minimum requirement for this visualization to work is two dimensions.
27 |
28 | The collapsible tree map is best utilized for cases where the user wants to map a lineage of high level to granular data. Visualization will start with one “empty” or blank node (0), and split off into a number of nested the the number of unique records from the first (furthest left) dimension in the explore, each represented by a new node (1).
29 |
30 | All subnodes will be collapsed by default and can be expanded by clicking.
31 |
32 |
--------------------------------------------------------------------------------
/src/examples/collapsible_tree/collapsible-tree.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/collapsible_tree/collapsible-tree.mov
--------------------------------------------------------------------------------
/src/examples/collapsible_tree/collapsible-tree.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/collapsible_tree/collapsible-tree.png
--------------------------------------------------------------------------------
/src/examples/collapsible_tree/collapsible_tree.ts:
--------------------------------------------------------------------------------
1 | // Global values provided via the API
2 | declare var looker: Looker
3 |
4 | import * as d3 from 'd3'
5 | import { handleErrors } from '../common/utils'
6 |
7 | import {
8 | Row,
9 | Looker,
10 | VisualizationDefinition
11 | } from '../types/types'
12 |
13 | interface CollapsibleTreeVisualization extends VisualizationDefinition {
14 | svg?: d3.Selection,
15 | }
16 |
17 | // recursively create children array
18 | function descend(obj: any, depth: number = 0) {
19 | const arr: any[] = []
20 | for (const k in obj) {
21 | if (k === '__data') {
22 | continue
23 | }
24 | const child: any = {
25 | name: k,
26 | depth,
27 | children: descend(obj[k], depth + 1)
28 | }
29 | if ('__data' in obj[k]) {
30 | child.data = obj[k].__data
31 | }
32 | arr.push(child)
33 | }
34 | return arr
35 | }
36 |
37 | function burrow(table: any, taxonomy: any[]) {
38 | // create nested object
39 | const obj: any = {}
40 |
41 | table.forEach((row: Row) => {
42 | // start at root
43 | let layer = obj
44 |
45 | // create children as nested objects
46 | taxonomy.forEach((t: any) => {
47 | const key = row[t.name].value
48 | layer[key] = key in layer ? layer[key] : {}
49 | layer = layer[key]
50 | })
51 | layer.__data = row
52 | })
53 |
54 | // use descend to create nested children arrays
55 | return {
56 | name: 'root',
57 | children: descend(obj, 1),
58 | depth: 0
59 | }
60 | }
61 |
62 | const vis: CollapsibleTreeVisualization = {
63 | id: 'collapsible_tree', // id/label not required, but nice for testing and keeping manifests in sync
64 | label: 'Collapsible Tree',
65 | options: {
66 | color_with_children: {
67 | label: 'Node Color With Children',
68 | default: '#36c1b3',
69 | type: 'string',
70 | display: 'color'
71 | },
72 | color_empty: {
73 | label: 'Empty Node Color',
74 | default: '#fff',
75 | type: 'string',
76 | display: 'color'
77 | }
78 | },
79 |
80 | // Set up the initial state of the visualization
81 | create(element, config) {
82 | this.svg = d3.select(element).append('svg');
83 | },
84 |
85 | // Render in response to the data or settings changing
86 | update(data, element, config, queryResponse) {
87 | if (!handleErrors(this, queryResponse, {
88 | min_pivots: 0, max_pivots: 0,
89 | min_dimensions: 2, max_dimensions: undefined,
90 | min_measures: 0, max_measures: undefined
91 | })) return
92 |
93 | let i = 0
94 | const nodeColors = {
95 | children: (config && config.color_with_children) || this.options.color_with_children.default,
96 | empty: (config && config.color_empty) || this.options.color_empty.default
97 | }
98 | const textSize = 10
99 | const nodeRadius = 4
100 | const duration = 750
101 | const margin = { top: 10, right: 10, bottom: 10, left: 10 }
102 | const width = element.clientWidth - margin.left - margin.right
103 | const height = element.clientHeight - margin.top - margin.bottom
104 | const nested = burrow(data, queryResponse.fields.dimension_like)
105 |
106 | const svg = this.svg!
107 | .html('')
108 | .attr('width', width + margin.right + margin.left)
109 | .attr('height', height + margin.top + margin.bottom)
110 | .append('g')
111 | .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
112 |
113 | // declares a tree layout and assigns the size
114 | const treemap = d3.tree().size([height, width])
115 |
116 | // Assigns parent, children, height, depth
117 | const rootNode: any = d3.hierarchy(nested, (d) => d.children)
118 | rootNode.x0 = height / 2
119 | rootNode.y0 = 0
120 |
121 | // define some helper functions that close over our local variables
122 |
123 | // Collapse the node and all it's children
124 | function collapse(d: any) {
125 | if (d.children) {
126 | d._children = d.children
127 | d._children.forEach(collapse)
128 | d.children = null
129 | }
130 | }
131 |
132 | // Creates a curved (diagonal) path from parent to the child nodes
133 | function diagonal(s: any, d: any) {
134 | const path = `
135 | M ${s.y} ${s.x}
136 | C ${(s.y + d.y) / 2} ${s.x},
137 | ${(s.y + d.y) / 2} ${d.x},
138 | ${d.y} ${d.x}
139 | `.trim()
140 |
141 | return path
142 | }
143 |
144 | // Toggle children on click.
145 | function click(d: any) {
146 | if (d.children) {
147 | d._children = d.children
148 | d.children = null
149 | } else {
150 | d.children = d._children
151 | d._children = null
152 | }
153 | update(d)
154 | }
155 |
156 | // Update the display for a given node
157 | function update(source: any) {
158 | // Assigns the x and y position for the nodes
159 | const treeData = treemap(rootNode)
160 |
161 | // Compute the new tree layout.
162 | const nodes = treeData.descendants()
163 | const links = treeData.descendants().slice(1)
164 |
165 | // Normalize for fixed-depth.
166 | nodes.forEach((d) => {
167 | d.y = d.depth * 180
168 | })
169 |
170 | // ****************** Nodes section ***************************
171 |
172 | // Update the nodes...
173 | const node: any = svg.selectAll('g.node').data(nodes, (d: any) => d.id || (d.id = ++i));
174 |
175 | // Enter any new modes at the parent's previous position.
176 | const nodeEnter = (
177 | node
178 | .enter()
179 | .append('g')
180 | .attr('class', 'node')
181 | .attr('transform', () => {
182 | return 'translate(' + source.y0 + ',' + source.x0 + ')'
183 | })
184 | .on('click', click)
185 | )
186 |
187 | // Add Circle for the nodes
188 | nodeEnter.append('circle')
189 | .attr('class', 'node')
190 | .attr('r', 1e-6)
191 |
192 | // Add labels for the nodes
193 | nodeEnter.append('text')
194 | .attr('dy', '.35em')
195 | .attr('x', (d: any) => {
196 | return d.children || d._children ? -textSize : textSize
197 | })
198 | .attr('text-anchor', (d: any) => {
199 | return d.children || d._children ? 'end' : 'start'
200 | })
201 | .style('font-family', "'Open Sans', Helvetica, sans-serif")
202 | .style('font-size', textSize + 'px')
203 | .text((d: any) => d.data.name)
204 |
205 | // UPDATE
206 | const nodeUpdate = nodeEnter.merge(node)
207 |
208 | // Transition to the proper position for the node
209 | nodeUpdate.transition()
210 | .duration(duration)
211 | .attr('transform', (d: any) => {
212 | return 'translate(' + d.y + ',' + d.x + ')'
213 | })
214 |
215 | // Update the node attributes and style
216 | nodeUpdate.select('circle.node')
217 | .attr('r', nodeRadius)
218 | .style('fill', (d: any) => d._children ? nodeColors.children : nodeColors.empty)
219 | .style('stroke', nodeColors.children)
220 | .style('stroke-width', 1.5)
221 | .attr('cursor', 'pointer')
222 |
223 | // Remove any exiting nodes
224 | const nodeExit = node.exit().transition()
225 | .duration(duration)
226 | .attr('transform', () => {
227 | return 'translate(' + source.y + ',' + source.x + ')'
228 | })
229 | .remove()
230 |
231 | // On exit reduce the node circles size to 0
232 | nodeExit.select('circle')
233 | .attr('r', 1e-6)
234 |
235 | // On exit reduce the opacity of text labels
236 | nodeExit.select('text')
237 | .style('fill-opacity', 1e-6)
238 |
239 | // ****************** links section ***************************
240 |
241 | // Update the links...
242 | const link: any = (
243 | svg
244 | .selectAll('path.link')
245 | .data(links, (d: any) => d.id)
246 | )
247 |
248 | // Enter any new links at the parent's previous position.
249 | const linkEnter = (
250 | link
251 | .enter()
252 | .insert('path', 'g')
253 | .attr('class', 'link')
254 | .style('fill', 'none')
255 | .style('stroke', '#ddd')
256 | .style('stroke-width', 1.5)
257 | .attr('d', () => {
258 | const o = { x: source.x0, y: source.y0 }
259 | return diagonal(o, o)
260 | })
261 | )
262 |
263 | // UPDATE
264 | const linkUpdate = linkEnter.merge(link)
265 |
266 | // Transition back to the parent element position
267 | linkUpdate
268 | .transition()
269 | .duration(duration)
270 | .attr('d', (d: any) => diagonal(d, d.parent))
271 |
272 | // Remove any exiting links
273 | link
274 | .exit()
275 | .transition()
276 | .duration(duration)
277 | .attr('d', () => {
278 | const o = { x: source.x, y: source.y }
279 | return diagonal(o, o)
280 | })
281 | .remove()
282 |
283 | // Store the old positions for transition.
284 | nodes.forEach((d: any) => {
285 | d.x0 = d.x
286 | d.y0 = d.y
287 | })
288 |
289 | }
290 |
291 | // Collapse after the second level
292 | rootNode.children.forEach(collapse)
293 |
294 | // Update the root node
295 | update(rootNode)
296 |
297 | }
298 | }
299 |
300 | looker.plugins.visualizations.add(vis)
301 |
--------------------------------------------------------------------------------
/src/examples/common/utils.ts:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 |
3 | import {
4 | VisConfig,
5 | VisQueryResponse,
6 | VisualizationDefinition
7 | } from '../types/types'
8 |
9 | export const formatType = (valueFormat: string) => {
10 | if (!valueFormat) return undefined
11 | let format = ''
12 | switch (valueFormat.charAt(0)) {
13 | case '$':
14 | format += '$'; break
15 | case '£':
16 | format += '£'; break
17 | case '€':
18 | format += '€'; break
19 | }
20 | if (valueFormat.indexOf(',') > -1) {
21 | format += ','
22 | }
23 | const splitValueFormat = valueFormat.split('.')
24 | format += '.'
25 | format += splitValueFormat.length > 1 ? splitValueFormat[1].length : 0
26 |
27 | switch (valueFormat.slice(-1)) {
28 | case '%':
29 | format += '%'; break
30 | case '0':
31 | format += 'f'; break
32 | }
33 | return d3.format(format)
34 | }
35 |
36 | export const handleErrors = (vis: VisualizationDefinition, res: VisQueryResponse, options: VisConfig) => {
37 |
38 | const check = (group: string, noun: string, count: number, min: number, max: number): boolean => {
39 | if (!vis.addError || !vis.clearErrors) return false
40 | if (count < min) {
41 | vis.addError({
42 | title: `Not Enough ${noun}s`,
43 | message: `This visualization requires ${min === max ? 'exactly' : 'at least'} ${min} ${noun.toLowerCase()}${ min === 1 ? '' : 's' }.`,
44 | group
45 | })
46 | return false
47 | }
48 | if (count > max) {
49 | vis.addError({
50 | title: `Too Many ${noun}s`,
51 | message: `This visualization requires ${min === max ? 'exactly' : 'no more than'} ${max} ${noun.toLowerCase()}${ min === 1 ? '' : 's' }.`,
52 | group
53 | })
54 | return false
55 | }
56 | vis.clearErrors(group)
57 | return true
58 | }
59 |
60 | const { pivots, dimensions, measure_like: measures } = res.fields
61 |
62 | return (check('pivot-req', 'Pivot', pivots.length, options.min_pivots, options.max_pivots)
63 | && check('dim-req', 'Dimension', dimensions.length, options.min_dimensions, options.max_dimensions)
64 | && check('mes-req', 'Measure', measures.length, options.min_measures, options.max_measures))
65 | }
66 |
--------------------------------------------------------------------------------
/src/examples/hello_world/hello_world.js:
--------------------------------------------------------------------------------
1 | looker.plugins.visualizations.add({
2 | // Id and Label are legacy properties that no longer have any function besides documenting
3 | // what the visualization used to have. The properties are now set via the manifest
4 | // form within the admin/visualizations page of Looker
5 | id: "hello_world",
6 | label: "Hello World",
7 | options: {
8 | font_size: {
9 | type: "string",
10 | label: "Font Size",
11 | values: [
12 | {"Large": "large"},
13 | {"Small": "small"}
14 | ],
15 | display: "radio",
16 | default: "large"
17 | }
18 | },
19 | // Set up the initial state of the visualization
20 | create: function(element, config) {
21 |
22 | // Insert a
40 | `;
41 |
42 | // Create a container element to let us center the text.
43 | var container = element.appendChild(document.createElement("div"));
44 | container.className = "hello-world-vis";
45 |
46 | // Create an element to contain the text.
47 | this._textElement = container.appendChild(document.createElement("div"));
48 |
49 | },
50 | // Render in response to the data or settings changing
51 | updateAsync: function(data, element, config, queryResponse, details, done) {
52 |
53 | // Clear any errors from previous updates
54 | this.clearErrors();
55 |
56 | // Throw some errors and exit if the shape of the data isn't what this chart needs
57 | if (queryResponse.fields.dimensions.length == 0) {
58 | this.addError({title: "No Dimensions", message: "This chart requires dimensions."});
59 | return;
60 | }
61 |
62 | // Grab the first cell of the data
63 | var firstRow = data[0];
64 | var firstCell = firstRow[queryResponse.fields.dimensions[0].name];
65 |
66 | // Insert the data into the page
67 | this._textElement.innerHTML = LookerCharts.Utils.htmlForCell(firstCell);
68 |
69 | // Set the size to the user-selected size
70 | if (config.font_size == "small") {
71 | this._textElement.className = "hello-world-text-small";
72 | } else {
73 | this._textElement.className = "hello-world-text-large";
74 | }
75 |
76 | // We are done rendering! Let Looker know.
77 | done()
78 | }
79 | });
80 |
--------------------------------------------------------------------------------
/src/examples/hello_world/hello_world.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/hello_world/hello_world.png
--------------------------------------------------------------------------------
/src/examples/hello_world/hello_world_error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/hello_world/hello_world_error.png
--------------------------------------------------------------------------------
/src/examples/hello_world_react/hello.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | // Create (or import) our react component
4 | export default class Hello extends React.Component {
5 | constructor (props) {
6 | // So we have access to 'this'
7 | super(props)
8 | }
9 |
10 | // render our data
11 | render() {
12 | return {this.props.data}
;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/examples/hello_world_react/hello_world.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/hello_world_react/hello_world.png
--------------------------------------------------------------------------------
/src/examples/hello_world_react/hello_world_error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/hello_world_react/hello_world_error.png
--------------------------------------------------------------------------------
/src/examples/hello_world_react/hello_world_react.js:
--------------------------------------------------------------------------------
1 | import Hello from './hello'
2 | import React from 'react'
3 | import ReactDOM from 'react-dom'
4 |
5 | looker.plugins.visualizations.add({
6 | // Id and Label are legacy properties that no longer have any function besides documenting
7 | // what the visualization used to have. The properties are now set via the manifest
8 | // form within the admin/visualizations page of Looker
9 | id: "react_test",
10 | label: "React Test",
11 | options: {
12 | font_size: {
13 | type: "string",
14 | label: "Font Size",
15 | values: [
16 | {"Large": "large"},
17 | {"Small": "small"}
18 | ],
19 | display: "radio",
20 | default: "large"
21 | }
22 | },
23 | // Set up the initial state of the visualization
24 | create: function(element, config) {
25 |
26 | // Insert a
44 | `;
45 |
46 | // Create a container element to let us center the text.
47 | let container = element.appendChild(document.createElement("div"));
48 | container.className = "hello-world-vis";
49 |
50 | // Create an element to contain the text.
51 | this._textElement = container.appendChild(document.createElement("div"));
52 |
53 | // Render to the target element
54 | this.chart = ReactDOM.render(
55 | ,
56 | this._textElement
57 | );
58 |
59 | },
60 | // Render in response to the data or settings changing
61 | updateAsync: function(data, element, config, queryResponse, details, done) {
62 |
63 | // Clear any errors from previous updates
64 | this.clearErrors();
65 |
66 | // Throw some errors and exit if the shape of the data isn't what this chart needs
67 | if (queryResponse.fields.dimensions.length == 0) {
68 | this.addError({title: "No Dimensions", message: "This chart requires dimensions."});
69 | return;
70 | }
71 |
72 | // Set the size to the user-selected size
73 | if (config.font_size == "small") {
74 | this._textElement.className = "hello-world-text-small";
75 | } else {
76 | this._textElement.className = "hello-world-text-large";
77 | }
78 |
79 | // Grab the first cell of the data
80 | let firstRow = data[0];
81 | const firstCell = firstRow[queryResponse.fields.dimensions[0].name].value;
82 |
83 | // Finally update the state with our new data
84 | this.chart = ReactDOM.render(
85 | ,
86 | this._textElement
87 | );
88 |
89 | // We are done rendering! Let Looker know.
90 | done()
91 | }
92 | });
93 |
--------------------------------------------------------------------------------
/src/examples/image_carousel/README.md:
--------------------------------------------------------------------------------
1 | # Comp Three Image Carousel
2 |
3 | This image carousel component allows for the display of images. These images can be provided either as image URLs which are publicly accessible or as a Base64 string in the database.
4 |
5 |
--------------------------------------------------------------------------------
/src/examples/image_carousel/c3_image_carousel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/image_carousel/c3_image_carousel.png
--------------------------------------------------------------------------------
/src/examples/image_carousel/constants.js:
--------------------------------------------------------------------------------
1 | export const CAROUSELCSS = `
2 | .carousel .control-arrow, .carousel.carousel-slider .control-arrow {
3 | -webkit-transition: all 0.25s ease-in;
4 | -moz-transition: all 0.25s ease-in;
5 | -ms-transition: all 0.25s ease-in;
6 | -o-transition: all 0.25s ease-in;
7 | transition: all 0.25s ease-in;
8 | opacity: 0.4;
9 | filter: alpha(opacity=40);
10 | position: absolute;
11 | z-index: 2;
12 | top: 20px;
13 | background: none;
14 | border: 0;
15 | font-size: 32px;
16 | cursor: pointer; }
17 | .carousel .control-arrow:hover {
18 | opacity: 1;
19 | filter: alpha(opacity=100); }
20 | .carousel .control-arrow:before, .carousel.carousel-slider .control-arrow:before {
21 | margin: 0 5px;
22 | display: inline-block;
23 | border-top: 8px solid transparent;
24 | border-bottom: 8px solid transparent;
25 | content: ''; }
26 | .carousel .control-disabled.control-arrow {
27 | opacity: 0;
28 | filter: alpha(opacity=0);
29 | cursor: inherit;
30 | display: none; }
31 | .carousel .control-prev.control-arrow {
32 | left: 0; }
33 | .carousel .control-prev.control-arrow:before {
34 | border-right: 8px solid #fff; }
35 | .carousel .control-next.control-arrow {
36 | right: 0; }
37 | .carousel .control-next.control-arrow:before {
38 | border-left: 8px solid #fff; }
39 |
40 | .carousel {
41 | position: relative;
42 | width: 100%; }
43 | .carousel * {
44 | -webkit-box-sizing: border-box;
45 | -moz-box-sizing: border-box;
46 | box-sizing: border-box; }
47 | .carousel img {
48 | width: 100%;
49 | display: inline-block;
50 | pointer-events: none; }
51 | .carousel .carousel {
52 | position: relative; }
53 | .carousel .control-arrow {
54 | outline: 0;
55 | border: 0;
56 | background: none;
57 | top: 50%;
58 | margin-top: -13px;
59 | font-size: 18px; }
60 | .carousel .thumbs-wrapper {
61 | margin: 20px;
62 | overflow: hidden; }
63 | .carousel .thumbs {
64 | -webkit-transition: all 0.15s ease-in;
65 | -moz-transition: all 0.15s ease-in;
66 | -ms-transition: all 0.15s ease-in;
67 | -o-transition: all 0.15s ease-in;
68 | transition: all 0.15s ease-in;
69 | -webkit-transform: translate3d(0, 0, 0);
70 | -moz-transform: translate3d(0, 0, 0);
71 | -ms-transform: translate3d(0, 0, 0);
72 | -o-transform: translate3d(0, 0, 0);
73 | transform: translate3d(0, 0, 0);
74 | position: relative;
75 | list-style: none;
76 | white-space: nowrap; }
77 | .carousel .thumb {
78 | -webkit-transition: border 0.15s ease-in;
79 | -moz-transition: border 0.15s ease-in;
80 | -ms-transition: border 0.15s ease-in;
81 | -o-transition: border 0.15s ease-in;
82 | transition: border 0.15s ease-in;
83 | display: inline-block;
84 | width: 80px;
85 | margin-right: 6px;
86 | white-space: nowrap;
87 | overflow: hidden;
88 | border: 3px solid #fff;
89 | padding: 2px; }
90 | .carousel .thumb:focus {
91 | border: 3px solid #ccc;
92 | outline: none; }
93 | .carousel .thumb.selected, .carousel .thumb:hover {
94 | border: 3px solid #333; }
95 | .carousel .thumb img {
96 | vertical-align: top; }
97 | .carousel.carousel-slider {
98 | position: relative;
99 | margin: 0;
100 | overflow: hidden; }
101 | .carousel.carousel-slider .control-arrow {
102 | top: 0;
103 | color: #fff;
104 | font-size: 26px;
105 | bottom: 0;
106 | margin-top: 0;
107 | padding: 5px; }
108 | .carousel.carousel-slider .control-arrow:hover {
109 | background: rgba(0, 0, 0, 0.2); }
110 | .carousel .slider-wrapper {
111 | overflow: hidden;
112 | margin: auto;
113 | width: 100%;
114 | -webkit-transition: height 0.15s ease-in;
115 | -moz-transition: height 0.15s ease-in;
116 | -ms-transition: height 0.15s ease-in;
117 | -o-transition: height 0.15s ease-in;
118 | transition: height 0.15s ease-in; }
119 | .carousel .slider-wrapper.axis-horizontal .slider {
120 | -ms-box-orient: horizontal;
121 | display: -webkit-box;
122 | display: -moz-box;
123 | display: -ms-flexbox;
124 | display: -moz-flex;
125 | display: -webkit-flex;
126 | display: flex; }
127 | .carousel .slider-wrapper.axis-horizontal .slider .slide {
128 | flex-direction: column;
129 | flex-flow: column; }
130 | .carousel .slider-wrapper.axis-vertical {
131 | -ms-box-orient: horizontal;
132 | display: -webkit-box;
133 | display: -moz-box;
134 | display: -ms-flexbox;
135 | display: -moz-flex;
136 | display: -webkit-flex;
137 | display: flex; }
138 | .carousel .slider-wrapper.axis-vertical .slider {
139 | -webkit-flex-direction: column;
140 | flex-direction: column; }
141 | .carousel .slider {
142 | margin: 0;
143 | padding: 0;
144 | position: relative;
145 | list-style: none;
146 | width: 100%; }
147 | .carousel .slider.animated {
148 | -webkit-transition: all 0.35s ease-in-out;
149 | -moz-transition: all 0.35s ease-in-out;
150 | -ms-transition: all 0.35s ease-in-out;
151 | -o-transition: all 0.35s ease-in-out;
152 | transition: all 0.35s ease-in-out; }
153 | .carousel .slide {
154 | min-width: 100%;
155 | margin: 0;
156 | position: relative;
157 | text-align: center;
158 | background: #000; }
159 | .carousel .slide img {
160 | width: 100%;
161 | vertical-align: top;
162 | border: 0; }
163 | .carousel .slide iframe {
164 | display: inline-block;
165 | width: calc(100% - 80px);
166 | margin: 0 40px 40px;
167 | border: 0; }
168 | .carousel .slide .legend {
169 | -webkit-transition: all 0.5s ease-in-out;
170 | -moz-transition: all 0.5s ease-in-out;
171 | -ms-transition: all 0.5s ease-in-out;
172 | -o-transition: all 0.5s ease-in-out;
173 | transition: all 0.5s ease-in-out;
174 | position: absolute;
175 | bottom: 40px;
176 | left: 50%;
177 | margin-left: -45%;
178 | width: 90%;
179 | border-radius: 10px;
180 | background: #000;
181 | color: #fff;
182 | padding: 10px;
183 | font-size: 12px;
184 | text-align: center;
185 | opacity: 0.25;
186 | -webkit-transition: opacity 0.35s ease-in-out;
187 | -moz-transition: opacity 0.35s ease-in-out;
188 | -ms-transition: opacity 0.35s ease-in-out;
189 | -o-transition: opacity 0.35s ease-in-out;
190 | transition: opacity 0.35s ease-in-out; }
191 | .carousel .control-dots {
192 | position: absolute;
193 | bottom: 0;
194 | margin: 10px 0;
195 | text-align: center;
196 | width: 100%; }
197 | @media (min-width: 960px) {
198 | .carousel .control-dots {
199 | bottom: 0; } }
200 | .carousel .control-dots .dot {
201 | -webkit-transition: opacity 0.25s ease-in;
202 | -moz-transition: opacity 0.25s ease-in;
203 | -ms-transition: opacity 0.25s ease-in;
204 | -o-transition: opacity 0.25s ease-in;
205 | transition: opacity 0.25s ease-in;
206 | opacity: 0.3;
207 | filter: alpha(opacity=30);
208 | box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.9);
209 | background: #fff;
210 | border-radius: 50%;
211 | width: 8px;
212 | height: 8px;
213 | cursor: pointer;
214 | display: inline-block;
215 | margin: 0 8px; }
216 | .carousel .control-dots .dot.selected, .carousel .control-dots .dot:hover {
217 | opacity: 1;
218 | filter: alpha(opacity=100); }
219 | .carousel .carousel-status {
220 | position: absolute;
221 | top: 0;
222 | right: 0;
223 | padding: 5px;
224 | font-size: 10px;
225 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.9);
226 | color: #fff; }
227 | .carousel:hover .slide .legend {
228 | opacity: 1; }
229 | `;
--------------------------------------------------------------------------------
/src/examples/image_carousel/imageViewer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Carousel } from 'react-responsive-carousel';
3 |
4 | const URLREGEX = new RegExp("((http|https)(:\/\/))?([a-zA-Z0-9]+[.]{1}){2}[a-zA-Z0-9]+(\/{1}[a-zA-Z0-9]+)*\/?", "igm");
5 | // Regular expression to check formal correctness of base64 encoded strings
6 | // https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/atob
7 | const b64re = /^(?:[A-Za-z\d+\/]{4})*?(?:[A-Za-z\d+\/]{2}(?:==)?|[A-Za-z\d+\/]{3}=?)?$/;
8 |
9 | const DOTS_THRESHOLD = 15;
10 |
11 | const isUrlCheck = (strToCheck) => {
12 | return URLREGEX.test(strToCheck);
13 | }
14 |
15 | const isBase64StringCheck = (strToCheck) => {
16 | if (strToCheck && strToCheck.length > 150 && b64re.test(strToCheck)) {
17 | try {
18 | return btoa(atob(strToCheck)) === strToCheck;
19 | } catch (err) {
20 | return false;
21 | }
22 | }
23 | return false;
24 | }
25 |
26 | // NOTE: this method loops over column key names without looking at column order
27 | const findImageCol = (stateData) => {
28 | let tmpImageColData = {
29 | type: {
30 | url: false,
31 | base64: false
32 | },
33 | name: null
34 | }
35 |
36 | for (let row of stateData) {
37 | for (let colName of Object.keys(row)) {
38 | if (isUrlCheck(row[colName].value)) {
39 | tmpImageColData.type.url = true;
40 | tmpImageColData.type.base64 = false; // set this in case we found b64 before finding a url
41 | tmpImageColData.name = colName;
42 | return tmpImageColData;
43 | } else if (isBase64StringCheck(row[colName].value)) {
44 | tmpImageColData.type.base64 = true;
45 | tmpImageColData.name = colName;
46 | }
47 | }
48 | // stop looping rows if we found a valid image column
49 | if (tmpImageColData.name) {
50 | return tmpImageColData;
51 | }
52 | }
53 | // resets this.state.imageColData to falsy if no valid image col was found
54 | return tmpImageColData;
55 | }
56 |
57 | // Create (or import) our react component
58 | export default class ImageViewer extends React.Component {
59 | constructor () {
60 | super();
61 |
62 | // Set initial state to a loading or no data message, initialize imageColData
63 | this.state = {
64 | data: null,
65 | queryResponse: null,
66 | imageColData: { // flatten
67 | type: {
68 | url: false,
69 | base64: false
70 | },
71 | name: null
72 | }
73 | };
74 | }
75 |
76 | // component mount/recv props, should component update - lifecycle method
77 | // if there is new data, call again to find column ...
78 |
79 | // render our data
80 | render() {
81 | if (!this.state.data) {
82 | return (
83 | No Image Data Found
84 | );
85 | }
86 |
87 | if (!this.state.imageColData.name) {
88 | this.state.imageColData = findImageCol(this.state.data);
89 | }
90 |
91 | // stop if no valid image data column found
92 | if (!this.state.imageColData.name) {
93 | return (
94 | Please select at least one field with an image url or a base64 encoded image.
95 | );
96 | }
97 |
98 | // check first row for the image column, if it is not present find the new valid image column
99 | // Rerun the image column check to make sure there is still a valid column, this needs to be refreshed when the
100 | // explore is changed in looker.
101 | if (typeof this.state.data[0][this.state.imageColData.name] === 'undefined') {
102 | this.state.imageColData = findImageCol(this.state.data);
103 | }
104 |
105 | let tableRows = this.state.data.map((row, idx) => {
106 | let val = row[this.state.imageColData.name].value; // image url or base64 string
107 |
108 | if (this.state.imageColData.type.base64) {
109 | val = `data:image/image;base64,${val}`;
110 | }
111 |
112 | return (
113 |
114 |

115 |
116 | );
117 | });
118 |
119 | // display image index linked dots in bottom of carousel, dots will stack into additional rows they overrun the carousel width
120 | let showIndicatorsBool = true;
121 | if (tableRows.length > DOTS_THRESHOLD) {
122 | showIndicatorsBool = false;
123 | }
124 |
125 | return (
126 |
127 | {tableRows}
128 |
129 | );
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/examples/image_carousel/image_carousel.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import ImageViewer from './imageViewer'
4 | import { CAROUSELCSS } from './constants';
5 |
6 |
7 | looker.plugins.visualizations.add({
8 | // Id and Label are legacy properties that no longer have any function besides documenting
9 | // what the visualization used to have. The properties are now set via the manifest
10 | // form within the admin/visualizations page of Looker
11 | id: "c3_image_carousel",
12 | label: "C3 Image Carousel",
13 | // Set up the initial state of the visualization
14 | create: function(element, config) {
15 | // Insert a `;
29 |
30 | // Create a container element to let us center the text.
31 | let container = element.appendChild(document.createElement("div"));
32 | container.className = "c3-image_carousel";
33 |
34 | // Create an element to contain the text.
35 | this._textElement = container.appendChild(document.createElement("div"));
36 |
37 | // Render to the target element
38 | this.chart = ReactDOM.render(
39 | ,
40 | this._textElement
41 | );
42 | },
43 | // Render in response to the data or settings changing
44 | updateAsync: function(data, element, config, queryResponse, details, done) {
45 |
46 | // Clear any errors from previous updates
47 | this.clearErrors();
48 |
49 | // Throw some errors and exit if the shape of the data isn't what this chart needs
50 | if (queryResponse.fields.dimensions.length == 0) {
51 | this.addError({title: "No Dimensions", message: "This chart requires dimensions."});
52 | return;
53 | }
54 |
55 | // Finally update the state with our new data
56 | this.chart.setState({data, queryResponse})
57 |
58 | // We are done rendering! Let Looker know.
59 | done()
60 | }
61 | });
62 |
--------------------------------------------------------------------------------
/src/examples/liquid_fill_gauge/README.md:
--------------------------------------------------------------------------------
1 | # Liquid Fill Gauge
2 |
3 | 
4 |
5 | This diagram displays a liquid fill gauge (LFG), displaying either a single measure value as a percentage, or a comparison of one measure to another measure.
6 |
7 | 
8 |
9 | **Implementation Instructions**
10 | Follow the instructions in [Looker's documentation](https://docs.looker.com/admin-options/platform/visualizations). Note that this viz does not require an SRI hash and has no dependencies. Simply create a unique ID, a label for the viz, and paste in the CDN link below.
11 |
12 | **CDN Link**
13 |
14 | Paste the following URL into the "Main" section of your Admin/Visualization page.
15 |
16 | https://looker-custom-viz-a.lookercdn.com/master/liquid_fill_gauge.js
17 |
18 | **How it Works**
19 |
20 | Create an explore with one or more measures, and no dimensions.
21 |
22 | **More Info**
23 |
24 | If comparing one measure to another measure, the first measure can be displayed as either a percent of the larger measure or the value itself. The visualization represents a thermometer, filling up as the measure gets closer to equivalence with the larger measure (or 100%).
25 |
26 | Including any dimensions in the query will either have no effect or will cause the measure to aggregate at a more granular level, in which case the visualization will display the top (in result grid) value of the first measure, compared to the top value of the second measure. Keep in mind, in such cases ordering may affect which row gets displayed.
27 |
--------------------------------------------------------------------------------
/src/examples/liquid_fill_gauge/liquid_fill_gauge.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * @license Open source under BSD 2-clause (http://choosealicense.com/licenses/bsd-2-clause/)
3 | * Copyright (c) 2015, Curtis Bratton
4 | * All rights reserved.
5 | *
6 | * This file was copied from https://raw.githubusercontent.com/ugomeda/d3-liquid-fill-gauge/master/liquidFillGauge.js
7 | * and modified for use in the Looker example custom visualizations project.
8 | */
9 |
10 | const SSF = require('ssf')
11 |
12 | var defaultConfig = {
13 | // Values
14 | minValue: 0, // The gauge minimum value.
15 | maxValue: 100, // The gauge maximum value.
16 |
17 | // Styles
18 | circleThickness: 0.05, // The outer circle thickness as a percentage of it's radius.
19 | circleFillGap: 0.05, // The size of the gap between the outer circle and wave circle as a percentage of the outer circles radius.
20 | circleColor: "#178BCA", // The color of the outer circle.
21 | backgroundColor: null, // The color of the background
22 | waveColor: "#178BCA", // The color of the fill wave.
23 | width: 0, // You might want to set the width and height if it is not detected properly by the plugin
24 | height: 0,
25 |
26 | // Gradient
27 | gradientFromColor: "#FFF",
28 | gradientToColor: "#000",
29 |
30 | // Waves
31 | waveHeight: 0.05, // The wave height as a percentage of the radius of the wave circle.
32 | waveCount: 1, // The number of full waves per width of the wave circle.
33 | waveOffset: 0, // The amount to initially offset the wave. 0 = no offset. 1 = offset of one full wave.
34 |
35 | // Animations
36 | waveRise: true, // Control if the wave should rise from 0 to it's full height, or start at it's full height.
37 | waveRiseTime: 1000, // The amount of time in milliseconds for the wave to rise from 0 to it's final height.
38 | waveRiseAtStart: true, // If set to false and waveRise at true, will disable only the initial animation
39 | waveAnimate: true, // Controls if the wave scrolls or is static.
40 | waveAnimateTime: 1800, // The amount of time in milliseconds for a full wave to enter the wave circle.
41 | waveHeightScaling: true, // Controls wave size scaling at low and high fill percentages. When true, wave height reaches it's maximum at 50% fill, and minimum at 0% and 100% fill. This helps to prevent the wave from making the wave circle from appear totally full or empty when near it's minimum or maximum fill.
42 | valueCountUp: true, // If true, the displayed value counts up from 0 to it's final value upon loading and updating. If false, the final value is displayed.
43 | valueCountUpAtStart: true, // If set to false and valueCountUp at true, will disable only the initial animation
44 |
45 | // Text
46 | textVertPosition: 0.5, // The height at which to display the percentage text withing the wave circle. 0 = bottom, 1 = top.
47 | textSize: 1, // The relative height of the text to display in the wave circle. 1 = 50%
48 | displayPercent: true, // If true, a % symbol is displayed after the value.
49 | textColor: "#045681", // The color of the value text when the wave does not overlap it.
50 | waveTextColor: "#A4DBf8", // The color of the value text when the wave overlaps it.
51 | };
52 |
53 | function initialize(d3) {
54 | var idGenerator = (function() {
55 | var count = 0;
56 | return function(prefix) {
57 | return prefix + "-" + count++;
58 | };
59 | })();
60 |
61 | d3.liquidfillgauge = function(g, value, settings, valueFormat) {
62 | // Handle configuration
63 | var config = d3.map(defaultConfig);
64 | d3.map(settings).each(function(val, key) {
65 | config.set(key, val);
66 | });
67 |
68 | g.each(function(d) {
69 | var gauge = d3.select(this);
70 |
71 | var width = config.get("width") !== 0 ? config.get("width") : parseInt(gauge.style("width"));
72 | var height = config.get("height") !== 0 ? config.get("height") : parseInt(gauge.style("height"));
73 | var radius = Math.min(width, height) / 2;
74 | var locationX = width / 2 - radius;
75 | var locationY = height / 2 - radius;
76 | var fillPercent = Math.max(config.get("minValue"), Math.min(config.get("maxValue"), value)) / config.get("maxValue");
77 |
78 | var waveHeightScale;
79 | if (config.get("waveHeightScaling")) {
80 | waveHeightScale = d3.scaleLinear()
81 | .range([0, config.get("waveHeight"), 0])
82 | .domain([0, 50, 100]);
83 | } else {
84 | waveHeightScale = d3.scaleLinear()
85 | .range([config.get("waveHeight"), config.get("waveHeight")])
86 | .domain([0, 100]);
87 | }
88 |
89 | var textPixels = (config.get("textSize") * radius / 2);
90 | var textFinalValue = parseFloat(value).toFixed(2);
91 | var textStartValue = config.get("valueCountUp") ? config.get("minValue") : textFinalValue;
92 | var percentText = config.get("displayPercent") ? "%" : "";
93 | var circleThickness = config.get("circleThickness") * radius;
94 | var circleFillGap = config.get("circleFillGap") * radius;
95 | var fillCircleMargin = circleThickness + circleFillGap;
96 | var fillCircleRadius = radius - fillCircleMargin;
97 | var waveHeight = fillCircleRadius * waveHeightScale(fillPercent * 100);
98 |
99 | var waveLength = fillCircleRadius * 2 / config.get("waveCount");
100 | var waveClipCount = 1 + config.get("waveCount");
101 | var waveClipWidth = waveLength * waveClipCount;
102 |
103 | // Rounding functions so that the correct number of decimal places is always displayed as the value counts up.
104 | var textRounder = function(value) {
105 | return Math.round(value);
106 | };
107 | if (parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))) {
108 | textRounder = function(value) {
109 | return parseFloat(value).toFixed(1);
110 | };
111 | }
112 | if (parseFloat(textFinalValue) != parseFloat(textRounder(textFinalValue))) {
113 | textRounder = function(value) {
114 | return parseFloat(value).toFixed(2);
115 | };
116 | }
117 | if (valueFormat !== null){
118 | textRounder = function(value) {
119 | return SSF.format(valueFormat, parseFloat(value))
120 | }
121 | }
122 |
123 |
124 | // Data for building the clip wave area.
125 | var data = [];
126 | for (var i = 0; i <= 40 * waveClipCount; i++) {
127 | data.push({
128 | x: i / (40 * waveClipCount),
129 | y: (i / (40))
130 | });
131 | }
132 |
133 | // Scales for drawing the outer circle.
134 | var gaugeCircleX = d3.scaleLinear().range([0, 2 * Math.PI]).domain([0, 1]);
135 | var gaugeCircleY = d3.scaleLinear().range([0, radius]).domain([0, radius]);
136 |
137 | // Scales for controlling the size of the clipping path.
138 | var waveScaleX = d3.scaleLinear().range([0, waveClipWidth]).domain([0, 1]);
139 | var waveScaleY = d3.scaleLinear().range([0, waveHeight]).domain([0, 1]);
140 |
141 | // Scales for controlling the position of the clipping path.
142 | var waveRiseScale = d3.scaleLinear()
143 | // The clipping area size is the height of the fill circle + the wave height, so we position the clip wave
144 | // such that the it will won't overlap the fill circle at all when at 0%, and will totally cover the fill
145 | // circle at 100%.
146 | .range([(fillCircleMargin + fillCircleRadius * 2 + waveHeight), (fillCircleMargin - waveHeight)])
147 | .domain([0, 1]);
148 | var waveAnimateScale = d3.scaleLinear()
149 | .range([0, waveClipWidth - fillCircleRadius * 2]) // Push the clip area one full wave then snap back.
150 | .domain([0, 1]);
151 |
152 | // Scale for controlling the position of the text within the gauge.
153 | var textRiseScaleY = d3.scaleLinear()
154 | .range([fillCircleMargin + fillCircleRadius * 2, (fillCircleMargin + textPixels * 0.7)])
155 | .domain([0, 1]);
156 |
157 | // Center the gauge within the parent
158 | var gaugeGroup = gauge.append("g")
159 | .attr('transform', 'translate(' + locationX + ',' + locationY + ')');
160 |
161 | // Draw the background circle
162 | if (config.get("backgroundColor")) {
163 | gaugeGroup.append("circle")
164 | .attr("r", radius)
165 | .style("fill", config.get("backgroundColor"))
166 | .attr('transform', 'translate(' + radius + ',' + radius + ')');
167 | }
168 |
169 | // Draw the outer circle.
170 | var gaugeCircleArc = d3.arc()
171 | .startAngle(gaugeCircleX(0))
172 | .endAngle(gaugeCircleX(1))
173 | .outerRadius(gaugeCircleY(radius))
174 | .innerRadius(gaugeCircleY(radius - circleThickness));
175 | gaugeGroup.append("path")
176 | .attr("d", gaugeCircleArc)
177 | .style("fill", config.get("circleColor"))
178 | .attr('transform', 'translate(' + radius + ',' + radius + ')');
179 |
180 | // Text where the wave does not overlap.
181 | var text1 = gaugeGroup.append("text")
182 | .attr("class", "liquidFillGaugeText")
183 | .attr("text-anchor", "middle")
184 | .attr("font-size", textPixels + "px")
185 | .style("fill", config.get("textColor"))
186 | .attr('transform', 'translate(' + radius + ',' + textRiseScaleY(config.get("textVertPosition")) + ')');
187 |
188 | // The clipping wave area.
189 | var clipArea = d3.area()
190 | .x(function(d) {
191 | return waveScaleX(d.x);
192 | })
193 | .y0(function(d) {
194 | return waveScaleY(Math.sin(Math.PI * 2 * config.get("waveOffset") * -1 + Math.PI * 2 * (1 - config.get("waveCount")) + d.y * 2 * Math.PI));
195 | })
196 | .y1(function(d) {
197 | return (fillCircleRadius * 2 + waveHeight);
198 | });
199 |
200 | var gaugeGroupDefs = gaugeGroup.append("defs");
201 |
202 | var clipId = idGenerator("clipWave");
203 | var waveGroup = gaugeGroupDefs
204 | .append("clipPath")
205 | .attr("id", clipId);
206 | var wave = waveGroup.append("path")
207 | .datum(data)
208 | .attr("d", clipArea);
209 |
210 | // The inner circle with the clipping wave attached.
211 | var fillCircleGroup = gaugeGroup.append("g")
212 | .attr("clip-path", "url(#" + clipId + ")");
213 | fillCircleGroup.append("circle")
214 | .attr("cx", radius)
215 | .attr("cy", radius)
216 | .attr("r", fillCircleRadius);
217 |
218 | if (config.get("fillWithGradient")) {
219 | var points = config.get("gradientPoints");
220 | var gradientId = idGenerator("linearGradient");
221 | var grad = gaugeGroupDefs.append("linearGradient")
222 | .attr("id", gradientId)
223 | .attr("x1", points[0])
224 | .attr("y1", points[1])
225 | .attr("x2", points[2])
226 | .attr("y2", points[3]);
227 | grad.append("stop")
228 | .attr("offset", "0")
229 | .attr("stop-color", config.get("gradientFromColor"));
230 | grad.append("stop")
231 | .attr("offset", "1")
232 | .attr("stop-color", config.get("gradientToColor"));
233 |
234 | fillCircleGroup.style("fill", "url(#" + gradientId + ")");
235 | } else {
236 | fillCircleGroup.style("fill", config.get("waveColor"));
237 | }
238 |
239 | // Text where the wave does overlap.
240 | var text2 = fillCircleGroup.append("text")
241 | .attr("class", "liquidFillGaugeText")
242 | .attr("text-anchor", "middle")
243 | .attr("font-size", textPixels + "px")
244 | .style("fill", config.get("waveTextColor"))
245 | .attr('transform', 'translate(' + radius + ',' + textRiseScaleY(config.get("textVertPosition")) + ')');
246 |
247 | // Make the wave rise. wave and waveGroup are separate so that horizontal and vertical movement can be controlled independently.
248 | var waveGroupXPosition = fillCircleMargin + fillCircleRadius * 2 - waveClipWidth;
249 |
250 | if (config.get("waveAnimate")) {
251 | var animateWave = function() {
252 | wave.transition()
253 | .duration(config.get("waveAnimateTime"))
254 | .ease(d3.easeLinear)
255 | .attr('transform', 'translate(' + waveAnimateScale(1) + ',0)')
256 | .on("end", function() {
257 | wave.attr('transform', 'translate(' + waveAnimateScale(0) + ',0)');
258 | animateWave();
259 | });
260 | };
261 | animateWave();
262 | }
263 |
264 | var transition = function(from, to, riseWave, animateText) {
265 | // Update texts and animate
266 | if (animateText) {
267 | var textTween = function() {
268 | var that = d3.select(this);
269 | var i = d3.interpolate(from, to);
270 | return function(t) {
271 | if(parseFloat(value) < parseFloat(i(t))) {
272 | that.text(textRounder(value) + percentText);
273 | return;
274 | }
275 | that.text(textRounder(i(t)) + percentText);
276 | };
277 | };
278 | text1.transition()
279 | .duration(config.get("waveRiseTime"))
280 | .tween("text", textTween);
281 | text2.transition()
282 | .duration(config.get("waveRiseTime"))
283 | .tween("text", textTween);
284 | } else {
285 | text1.text(textRounder(to) + percentText);
286 | text2.text(textRounder(to) + percentText);
287 | }
288 |
289 | // Update the wave
290 | const toPercent = Math.max(config.get("minValue"), Math.min(config.get("maxValue"), to)) / config.get("maxValue");
291 | const fromPercent = Math.max(config.get("minValue"), Math.min(config.get("maxValue"), from)) / config.get("maxValue");
292 |
293 | if (riseWave) {
294 | waveGroup.attr('transform', 'translate(' + waveGroupXPosition + ',' + waveRiseScale(fromPercent) + ')')
295 | .transition()
296 | .duration(config.get("waveRiseTime"))
297 | .attr('transform', 'translate(' + waveGroupXPosition + ',' + waveRiseScale(toPercent) + ')');
298 | } else {
299 | waveGroup.attr('transform', 'translate(' + waveGroupXPosition + ',' + waveRiseScale(toPercent) + ')');
300 | }
301 | };
302 |
303 | transition(
304 | textStartValue,
305 | textFinalValue,
306 | config.get("waveRise") && config.get("waveRiseAtStart"),
307 | config.get("valueCountUp") && config.get("valueCountUpAtStart")
308 | );
309 |
310 | // Event to update the value
311 | gauge.on("valueChanged", function(newValue) {
312 | transition(value, newValue, config.get("waveRise"), config.get("valueCountUp"));
313 | value = newValue;
314 | });
315 |
316 | gauge.on("destroy", function() {
317 | // Stop all the transitions
318 | text1.interrupt().transition();
319 | text2.interrupt().transition();
320 | waveGroup.interrupt().transition();
321 | wave.interrupt().transition();
322 |
323 | // Unattach events
324 | gauge.on("valueChanged", null);
325 | gauge.on("destroy", null);
326 | });
327 | });
328 | };
329 | }
330 |
331 | module.exports = { initialize, defaultConfig }
--------------------------------------------------------------------------------
/src/examples/liquid_fill_gauge/liquid_fill_gauge.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/liquid_fill_gauge/liquid_fill_gauge.mov
--------------------------------------------------------------------------------
/src/examples/liquid_fill_gauge/liquid_fill_gauge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/liquid_fill_gauge/liquid_fill_gauge.png
--------------------------------------------------------------------------------
/src/examples/liquid_fill_gauge/liquid_fill_gauge.ts:
--------------------------------------------------------------------------------
1 | // Global values provided via the API
2 | declare var looker: Looker
3 |
4 | import * as d3 from 'd3'
5 | import { handleErrors } from '../common/utils'
6 |
7 | import * as LiquidFillGauge from './liquid_fill_gauge.js'
8 |
9 | // @ts-ignore
10 | LiquidFillGauge.initialize(d3)
11 |
12 | import { Looker, VisualizationDefinition } from '../types/types'
13 |
14 | interface LiquidFillGaugeVisualization extends VisualizationDefinition {
15 | svg?: any
16 | }
17 |
18 | // @ts-ignore
19 | const defaults: any = LiquidFillGauge.defaultConfig
20 |
21 | const vis: LiquidFillGaugeVisualization = {
22 | id: 'liquid_fill_gauge', // id/label not required, but nice for testing and keeping manifests in sync
23 | label: 'Liquid Fill Gauge',
24 | options: {
25 | showComparison: {
26 | label: 'Use field comparison',
27 | default: false,
28 | section: 'Value',
29 | type: 'boolean'
30 | },
31 | minValue: {
32 | label: 'Min value',
33 | min: 0,
34 | default: defaults.minValue,
35 | section: 'Value',
36 | type: 'number',
37 | placeholder: 'Any positive number'
38 | },
39 | maxValue: {
40 | label: 'Max value',
41 | min: 0,
42 | default: defaults.maxValue,
43 | section: 'Value',
44 | type: 'number',
45 | placeholder: 'Any positive number'
46 | },
47 | circleThickness: {
48 | label: 'Circle Thickness',
49 | min: 0,
50 | max: 1,
51 | step: 0.05,
52 | default: defaults.circleThickness,
53 | section: 'Style',
54 | type: 'number',
55 | display: 'range'
56 | },
57 | circleFillGap: {
58 | label: 'Circle Gap',
59 | min: 0,
60 | max: 1,
61 | step: 0.05,
62 | default: defaults.circleFillGap,
63 | section: 'Style',
64 | type: 'number',
65 | display: 'range'
66 | },
67 | circleColor: {
68 | label: 'Circle Color',
69 | default: defaults.circleColor,
70 | section: 'Style',
71 | type: 'string',
72 | display: 'color'
73 | },
74 | waveHeight: {
75 | label: 'Wave Height',
76 | min: 0,
77 | max: 1,
78 | step: 0.05,
79 | default: defaults.waveHeight,
80 | section: 'Waves',
81 | type: 'number',
82 | display: 'range'
83 | },
84 | waveCount: {
85 | label: 'Wave Count',
86 | min: 0,
87 | max: 10,
88 | default: defaults.waveCount,
89 | section: 'Waves',
90 | type: 'number',
91 | display: 'range'
92 | },
93 | waveRiseTime: {
94 | label: 'Wave Rise Time',
95 | min: 0,
96 | max: 5000,
97 | step: 50,
98 | default: defaults.waveRiseTime,
99 | section: 'Waves',
100 | type: 'number',
101 | display: 'range'
102 | },
103 | waveAnimateTime: {
104 | label: 'Wave Animation Time',
105 | min: 1,
106 | max: 5000,
107 | step: 50,
108 | default: defaults.waveAnimateTime,
109 | section: 'Waves',
110 | type: 'number',
111 | display: 'range'
112 | },
113 | waveRise: {
114 | label: 'Wave Rise from Bottom',
115 | default: defaults.waveRise,
116 | section: 'Waves',
117 | type: 'boolean'
118 | },
119 | waveHeightScaling: {
120 | label: 'Scale waves if high or low',
121 | default: defaults.waveHeightScaling,
122 | section: 'Waves',
123 | type: 'boolean'
124 | },
125 | waveAnimate: {
126 | label: 'Animate Waves',
127 | default: true,
128 | section: 'Waves',
129 | type: 'boolean'
130 | },
131 | waveColor: {
132 | label: 'Wave Color',
133 | default: '#64518A',
134 | section: 'Style',
135 | type: 'string',
136 | display: 'color'
137 | },
138 | waveOffset: {
139 | label: 'Wave Offset',
140 | min: 0,
141 | max: 1,
142 | step: 0.05,
143 | default: 0,
144 | section: 'Waves',
145 | type: 'number',
146 | display: 'range'
147 | },
148 | textVertPosition: {
149 | label: 'Text Vertical Offset',
150 | min: 0,
151 | max: 1,
152 | step: 0.01,
153 | default: 0.5,
154 | section: 'Value',
155 | type: 'number',
156 | display: 'range'
157 | },
158 | textSize: {
159 | label: 'Text Size',
160 | min: 0,
161 | max: 1,
162 | step: 0.01,
163 | default: 1,
164 | section: 'Value',
165 | type: 'number',
166 | display: 'range'
167 | },
168 | valueCountUp: {
169 | label: 'Animate to Value',
170 | default: true,
171 | section: 'Waves',
172 | type: 'boolean'
173 | },
174 | displayPercent: {
175 | label: 'Display as Percent',
176 | default: true,
177 | section: 'Value',
178 | type: 'boolean'
179 | },
180 | textColor: {
181 | label: 'Text Color (non-overlapped)',
182 | default: '#000000',
183 | section: 'Style',
184 | type: 'string',
185 | display: 'color'
186 | },
187 | waveTextColor: {
188 | label: 'Text Color (overlapped)',
189 | default: '#FFFFFF',
190 | section: 'Style',
191 | type: 'string',
192 | display: 'color'
193 | }
194 | },
195 | // Set up the initial state of the visualization
196 | create(element, config) {
197 | element.style.margin = '10px'
198 | element.style.fontFamily = `"Open Sans", "Helvetica", sans-serif`
199 | element.innerHTML = `
200 |
205 | `
206 | const elementId = `fill-gauge-${Date.now()}`
207 | this.svg = d3.select(element).append('svg')
208 | this.svg.attr('id', elementId)
209 | },
210 | // Render in response to the data or settings changing
211 | update(data, element, config, queryResponse, details) {
212 | if (!handleErrors(this, queryResponse, {
213 | min_pivots: 0, max_pivots: 0,
214 | min_dimensions: 0, max_dimensions: undefined,
215 | min_measures: 1, max_measures: undefined
216 | })) return
217 |
218 | // @ts-ignore
219 | const gaugeConfig = Object.assign(LiquidFillGauge.defaultConfig, config)
220 |
221 | if (this.addError && this.clearErrors) {
222 | if (gaugeConfig.maxValue <= 0) {
223 | this.addError({ group: 'config', title: 'Max value must be greater than zero.' })
224 | return
225 | } else if (data.length === 0) {
226 | this.addError({ title: 'No Results' })
227 | return
228 | } else {
229 | this.clearErrors('config')
230 | }
231 | }
232 |
233 | const datumField = queryResponse.fields.measure_like[0]
234 | const valueFormat = gaugeConfig.displayPercent ? null : datumField.value_format
235 | const datum = data[0][datumField.name]
236 | let value = datum.value
237 |
238 | const compareField = queryResponse.fields.measure_like[1]
239 | if (compareField && gaugeConfig.showComparison) {
240 | const compareDatum = data[0][compareField.name]
241 | gaugeConfig.maxValue = compareDatum.value
242 | }
243 |
244 | if (gaugeConfig.displayPercent) {
245 | value = datum.value / gaugeConfig.maxValue * 100
246 | gaugeConfig.maxValue = 100
247 | }
248 |
249 | this.svg.html('')
250 | this.svg.attr('width', element.clientWidth - 20)
251 | this.svg.attr('height', element.clientHeight - 20)
252 |
253 | // @ts-ignore
254 | if (details['print']) {
255 | Object.assign(gaugeConfig, {
256 | valueCountUp: false,
257 | waveAnimateTime: 0,
258 | waveRiseTime: 0,
259 | waveAnimate: false,
260 | waveRise: false
261 | })
262 | }
263 | // @ts-ignore
264 | d3.liquidfillgauge(this.svg, value, gaugeConfig, valueFormat)
265 |
266 | }
267 | }
268 |
269 | looker.plugins.visualizations.add(vis)
270 |
--------------------------------------------------------------------------------
/src/examples/sankey/README.md:
--------------------------------------------------------------------------------
1 | # Sankey
2 |
3 | 
4 |
5 | This visualization creates a [sankey diagram](https://en.wikipedia.org/wiki/Sankey_diagram), which displays sequences of transitions. Sankey diagrams are a specific type of flow diagram, in which the width of each arrow is proportional to the flow quantity. Sankey diagrams are often used in scientific fields, especially physics. They are used to represent energy inputs, useful outputs, and wasted outputs. Sankeys can also be used to understand how users arrive at and navigate through an application or website.
6 |
7 | Good use cases include:
8 | - Event Analytics
9 | - Energy Flows
10 | - Order Stages
11 |
12 | 
13 |
14 | **Implementation Instructions**
15 | Follow the instructions in [Looker's documentation](https://docs.looker.com/admin-options/platform/visualizations). Note that this viz does not require an SRI hash and has no dependencies. Simply create a unique ID, a label for the viz, and paste in the CDN link below.
16 |
17 | **CDN Link**
18 |
19 | Paste the following URL into the "Main" section of your Admin/Visualization page.
20 |
21 | https://looker-custom-viz-a.lookercdn.com/master/sankey.js
22 |
23 |
24 | **How it works**
25 |
26 | Create a Look with any number of dimensions and one measure.
27 |
28 | For example, in the sankey diagram above, you can see event transition counts between the various sequences of states.
29 |
30 | **More Info**
31 |
32 | The Sankey visualization displays the “flow” of data from one dimension to another. This visualization is best used when multiple dimensions are included in the data pane that are related to each other in a hierarchical and sequential way.
33 |
34 | A good example use case of this visualization is cost analysis. Accounting may be interested in where exactly the total costs incurred for a given month went. The Sankey graph can show the proportion of dollars that went to different categories and subcategories, in an intuitive way. From left to right, the Sankey graph would start with the total dollar amount of costs incurred for that month, then split into subsequent categories, determined by the second dimension, summed at the appropriate level of granularity. It is best used with relatively high levels of aggregation, as too many categories can clutter the chart and make it difficult to interpret.
35 |
--------------------------------------------------------------------------------
/src/examples/sankey/sankey.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/sankey/sankey.mov
--------------------------------------------------------------------------------
/src/examples/sankey/sankey.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/sankey/sankey.png
--------------------------------------------------------------------------------
/src/examples/sankey/sankey.ts:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import { sankey, sankeyLinkHorizontal, sankeyLeft } from 'd3-sankey'
3 | import { handleErrors } from '../common/utils'
4 |
5 | import {
6 | Cell,
7 | Link,
8 | Looker,
9 | LookerChartUtils,
10 | VisualizationDefinition
11 | } from '../types/types'
12 |
13 | // Global values provided via the API
14 | declare var looker: Looker
15 | declare var LookerCharts: LookerChartUtils
16 |
17 | interface Sankey extends VisualizationDefinition {
18 | svg?: any
19 | }
20 |
21 | const vis: Sankey = {
22 | id: 'sankey', // id/label not required, but nice for testing and keeping manifests in sync
23 | label: 'Sankey',
24 | options: {
25 | color_range: {
26 | type: 'array',
27 | label: 'Color Range',
28 | display: 'colors',
29 | default: ['#dd3333', '#80ce5d', '#f78131', '#369dc1', '#c572d3', '#36c1b3', '#b57052', '#ed69af']
30 | },
31 | label_type: {
32 | default: 'name',
33 | display: 'select',
34 | label: 'Label Type',
35 | type: 'string',
36 | values: [
37 | { 'Name': 'name' },
38 | { 'Name (value)': 'name_value' }
39 | ]
40 | },
41 | show_null_points: {
42 | type: 'boolean',
43 | label: 'Plot Null Values',
44 | default: true
45 | }
46 | },
47 | // Set up the initial state of the visualization
48 | create (element, config) {
49 | element.innerHTML = `
50 |
56 | `
57 | this.svg = d3.select(element).append('svg')
58 | },
59 | // Render in response to the data or settings changing
60 | updateAsync (data, element, config, queryResponse, details, doneRendering) {
61 | if (!handleErrors(this, queryResponse, {
62 | min_pivots: 0, max_pivots: 0,
63 | min_dimensions: 2, max_dimensions: undefined,
64 | min_measures: 1, max_measures: 1
65 | })) return
66 |
67 | const width = element.clientWidth
68 | const height = element.clientHeight
69 |
70 | const svg = this.svg
71 | .html('')
72 | .attr('width', '100%')
73 | .attr('height', '100%')
74 | .append('g')
75 |
76 | const dimensions = queryResponse.fields.dimension_like
77 | const measure = queryResponse.fields.measure_like[0]
78 |
79 | // The standard d3.ScaleOrdinal, causes error
80 | // `no-inferred-empty-object-type Explicit type parameter needs to be provided to the function call`
81 | // https://stackoverflow.com/questions/31564730/typescript-with-d3js-with-definitlytyped
82 | const color = d3.scaleOrdinal()
83 | .range(config.color_range || vis.options.color_range.default)
84 |
85 | const defs = svg.append('defs')
86 |
87 | const sankeyInst = sankey()
88 | .nodeAlign(sankeyLeft)
89 | .nodeWidth(10)
90 | .nodePadding(12)
91 | .extent([[1, 1], [width - 1, height - 6]])
92 |
93 | // TODO: Placeholder until @types catches up with sankey
94 | const newSankeyProps: any = sankeyInst
95 | newSankeyProps.nodeSort(null)
96 |
97 | let link = svg.append('g')
98 | .attr('class', 'links')
99 | .attr('fill', 'none')
100 | .attr('stroke', '#fff')
101 | .selectAll('path')
102 |
103 | let node = svg.append('g')
104 | .attr('class', 'nodes')
105 | .attr('font-family', 'sans-serif')
106 | .attr('font-size', 10)
107 | .selectAll('g')
108 |
109 | const graph: any = {
110 | nodes: [],
111 | links: []
112 | }
113 |
114 | const nodes = d3.set()
115 |
116 | data.forEach(function (d: any) {
117 | // variable number of dimensions
118 | const path: any[] = []
119 | for (const dim of dimensions) {
120 | if (d[dim.name].value === null && !config.show_null_points) break
121 | path.push(d[dim.name].value + '')
122 | }
123 | path.forEach(function (p: any, i: number) {
124 | if (i === path.length - 1) return
125 | const source: any = path.slice(i, i + 1)[0] + i + `len:${path.slice(i, i + 1)[0].length}`
126 | const target: any = path.slice(i + 1, i + 2)[0] + (i + 1) + `len:${path.slice(i + 1, i + 2)[0].length}`
127 | nodes.add(source)
128 | nodes.add(target)
129 | // Setup drill links
130 | const drillLinks: Link[] = []
131 | for (const key in d) {
132 | if (d[key].links) {
133 | d[key].links.forEach((link: Link) => { drillLinks.push(link) })
134 | }
135 | }
136 |
137 | graph.links.push({
138 | 'drillLinks': drillLinks,
139 | 'source': source,
140 | 'target': target,
141 | 'value': +d[measure.name].value
142 | })
143 | })
144 | })
145 |
146 | const nodesArray = nodes.values()
147 |
148 | graph.links.forEach(function (d: Cell) {
149 | d.source = nodesArray.indexOf(d.source)
150 | d.target = nodesArray.indexOf(d.target)
151 | })
152 |
153 | graph.nodes = nodes.values().map((d: any) => {
154 | return {
155 | name: d.slice(0, d.split('len:')[1])
156 | }
157 | })
158 |
159 | sankeyInst(graph)
160 |
161 | link = link
162 | .data(graph.links)
163 | .enter().append('path')
164 | .attr('class', 'link')
165 | .attr('d', function (d: any) { return 'M' + -10 + ',' + -10 + sankeyLinkHorizontal()(d) })
166 | .style('opacity', 0.4)
167 | .attr('stroke-width', function (d: Cell) { return Math.max(1, d.width) })
168 | .on('mouseenter', function (this: any, d: Cell) {
169 | svg.selectAll('.link')
170 | .style('opacity', 0.05)
171 | d3.select(this)
172 | .style('opacity', 0.7)
173 | svg.selectAll('.node')
174 | .style('opacity', function (p: any) {
175 | if (p === d.source) return 1
176 | if (p === d.target) return 1
177 | return 0.5
178 | })
179 | })
180 | .on('click', function (this: any, d: Cell) {
181 | // Add drill menu event
182 | const coords = d3.mouse(this)
183 | const event: object = { pageX: coords[0], pageY: coords[1] }
184 | LookerCharts.Utils.openDrillMenu({
185 | links: d.drillLinks,
186 | event: event
187 | })
188 | })
189 | .on('mouseleave', function (d: Cell) {
190 | d3.selectAll('.node').style('opacity', 1)
191 | d3.selectAll('.link').style('opacity', 0.4)
192 | })
193 |
194 | // gradients https://bl.ocks.org/micahstubbs/bf90fda6717e243832edad6ed9f82814
195 | link.style('stroke', function (d: Cell, i: number) {
196 |
197 | // make unique gradient ids
198 | const gradientID = 'gradient' + i
199 |
200 | const startColor = color(d.source.name.replace(/ .*/, ''))
201 | const stopColor = color(d.target.name.replace(/ .*/, ''))
202 |
203 | const linearGradient = defs.append('linearGradient')
204 | .attr('id', gradientID)
205 |
206 | linearGradient.selectAll('stop')
207 | .data([
208 | { offset: '10%', color: startColor },
209 | { offset: '90%', color: stopColor }
210 | ])
211 | .enter().append('stop')
212 | .attr('offset', function (d: Cell) {
213 | return d.offset
214 | })
215 | .attr('stop-color', function (d: Cell) {
216 | return d.color
217 | })
218 |
219 | return 'url(#' + gradientID + ')'
220 | })
221 |
222 | node = node
223 | .data(graph.nodes)
224 | .enter().append('g')
225 | .attr('class', 'node')
226 | .on('mouseenter', function (d: Cell) {
227 | svg.selectAll('.link')
228 | .style('opacity', function (p: any) {
229 | if (p.source === d) return 0.7
230 | if (p.target === d) return 0.7
231 | return 0.05
232 | })
233 | })
234 | .on('mouseleave', function (d: Cell) {
235 | d3.selectAll('.link').style('opacity', 0.4)
236 | })
237 |
238 | node.append('rect')
239 | .attr('x', function (d: Cell) { return d.x0 })
240 | .attr('y', function (d: Cell) { return d.y0 })
241 | .attr('height', function (d: Cell) { return Math.abs(d.y1 - d.y0) })
242 | .attr('width', function (d: Cell) { return Math.abs(d.x1 - d.x0) })
243 | .attr('fill', function (d: Cell) { return color(d.name.replace(/ .*/, '')) })
244 | .attr('stroke', '#555')
245 |
246 | node.append('text')
247 | .attr('x', function (d: Cell) { return d.x0 - 6 })
248 | .attr('y', function (d: Cell) { return (d.y1 + d.y0) / 2 })
249 | .attr('dy', '0.35em')
250 | .style('font-weight', 'bold')
251 | .attr('text-anchor', 'end')
252 | .style('fill', '#222')
253 | .text(function (d: Cell) {
254 | switch (config.label_type) {
255 | case 'name':
256 | return d.name
257 | case 'name_value':
258 | return `${d.name} (${d.value})`
259 | default:
260 | return ''
261 | }
262 | })
263 | .filter(function (d: Cell) { return d.x0 < width / 2 })
264 | .attr('x', function (d: Cell) { return d.x1 + 6 })
265 | .attr('text-anchor', 'start')
266 |
267 | node.append('title')
268 | .text(function (d: Cell) { return d.name + '\n' + d.value })
269 | doneRendering()
270 | }
271 | }
272 | looker.plugins.visualizations.add(vis)
273 |
--------------------------------------------------------------------------------
/src/examples/subtotal/README.md:
--------------------------------------------------------------------------------
1 | # Subtotal
2 |
3 | 
4 |
5 | This visualization groups query results by measures and pivots and allows collapsing/expanding of those groups.
6 |
7 | **Implementation Instructions**
8 | Follow the instructions in [Looker's documentation](https://docs.looker.com/admin-options/platform/visualizations). Note that this viz does not require an SRI hash and has no dependencies. Simply create a unique ID, a label for the viz, and paste in the CDN link below.
9 |
10 | **CDN Link**
11 |
12 | Paste the following URL into the "Main" section of your Admin/Visualization page.
13 |
14 | https://looker-custom-viz-a.lookercdn.com/master/subtotal.js
15 |
16 |
17 | **How it works**
18 |
19 | Create a Look with at least one dimension and one measure.
20 |
21 | For example, in the example above, we have grouped products by category and brand and by the year they were ordered. We've also pivoted by department to show the number of orders of those products by department. Row totals appear on the right side of the table.
22 |
--------------------------------------------------------------------------------
/src/examples/subtotal/subtotal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/subtotal/subtotal.png
--------------------------------------------------------------------------------
/src/examples/subtotal/subtotal.ts:
--------------------------------------------------------------------------------
1 | import * as $ from 'jquery'
2 | import 'pivottable'
3 | import subtotalMultipleAggregates from 'subtotal-multiple-aggregates'
4 | import { handleErrors, formatType } from '../common/utils'
5 |
6 | declare var require: any
7 | const themeClassic = require('subtotal-multiple-aggregates/dist/looker-classic.css')
8 | const themeWhite = require('subtotal-multiple-aggregates/dist/looker-white.css')
9 |
10 | import { Looker, VisualizationDefinition, LookerChartUtils, Cell } from '../types/types'
11 |
12 | declare var looker: Looker
13 | declare var LookerCharts: LookerChartUtils
14 |
15 | type Formatter = ((s: any) => string)
16 | const defaultFormatter: Formatter = (x) => x.toString()
17 |
18 | const LOOKER_ROW_TOTAL_KEY = '$$$_row_total_$$$'
19 |
20 | subtotalMultipleAggregates($)
21 |
22 | interface Subtotal extends VisualizationDefinition {
23 | style?: HTMLElement
24 | }
25 |
26 | const vis: Subtotal = {
27 | id: 'subtotal',
28 | label: 'Subtotal',
29 |
30 | options: {
31 | theme: {
32 | type: 'string',
33 | label: 'Theme',
34 | display: 'select',
35 | values: [
36 | { 'Classic': 'classic' },
37 | { 'White': 'white' }
38 | ],
39 | default: 'classic'
40 | },
41 | show_full_field_name: {
42 | type: 'boolean',
43 | label: 'Show Full Field Name',
44 | default: true
45 | }
46 | },
47 |
48 | create (element, config) {
49 | this.style = document.createElement('style')
50 | document.head.appendChild(this.style)
51 | },
52 |
53 | update (data, element, config, queryResponse, details) {
54 | if (!config || !data) return
55 | if (details && details.changed && details.changed.size) return
56 | if (!this.style) return
57 |
58 | if (!handleErrors(this, queryResponse, {
59 | min_pivots: 0, max_pivots: Infinity,
60 | min_dimensions: 1, max_dimensions: Infinity,
61 | min_measures: 1, max_measures: Infinity
62 | })) return
63 |
64 | const theme = config.theme || this.options.theme.default
65 | switch (theme) {
66 | case 'classic':
67 | this.style.innerHTML = themeClassic.toString()
68 | break
69 | case 'white':
70 | this.style.innerHTML = themeWhite.toString()
71 | break
72 | default:
73 | throw new Error(`Unknown theme: ${theme}`)
74 | }
75 |
76 | const pivots: string[] = queryResponse.fields.pivots.map((d: any) => d.name)
77 | const dimensions: string[] = queryResponse.fields.dimensions.map((d: any) => d.name)
78 | const measures = queryResponse.fields.measures
79 |
80 | const labels: { [key: string]: any } = {}
81 | for (const key of Object.keys(config.query_fields)) {
82 | const obj = config.query_fields[key]
83 | for (const field of obj) {
84 | const { name, view_label: label1, label_short: label2 } = field
85 | labels[name] = config.show_full_field_name ? { label: label1, sublabel: label2 } : { label: label2 }
86 | }
87 | }
88 |
89 | const pivotSet: { [key: string]: boolean } = {}
90 | for (const pivot of pivots) {
91 | pivotSet[pivot] = true
92 | }
93 |
94 | const htmlForCell = (cell: Cell) => {
95 | return cell.html ? LookerCharts.Utils.htmlForCell(cell) : cell.value
96 | }
97 |
98 | const ptData = []
99 | for (const row of data) {
100 | const ptRow: { [key: string]: any } = {}
101 | for (const key of Object.keys(row)) {
102 | const cell = row[key] as Cell
103 | if (pivotSet[key]) continue
104 | const cellValue = htmlForCell(cell)
105 | ptRow[key] = cellValue
106 | }
107 | if (pivots.length === 0) {
108 | // No pivoting, just add each data row.
109 | ptData.push(ptRow)
110 | } else {
111 | // Fan out each row using the pivot. Multiple pivots are joined by `|FIELD|`.
112 | for (const flatKey of Object.keys(row[measures[0].name])) {
113 | const pivotRow = Object.assign({}, ptRow)
114 | if (flatKey === LOOKER_ROW_TOTAL_KEY) {
115 | for (const pivotKey of Object.keys(row[measures[0].name])) {
116 | for (const pivot of pivots) {
117 | pivotRow[pivot] = LOOKER_ROW_TOTAL_KEY
118 | }
119 | for (const measure of measures) {
120 | const cell = row[measure.name][pivotKey] as Cell
121 | const cellValue = htmlForCell(cell)
122 | pivotRow[measure.name] = cellValue
123 | }
124 | }
125 | } else {
126 | const pivotValues = flatKey.split(/\|FIELD\|/g)
127 | for (let i = 0; i < pivots.length; i++) {
128 | pivotRow[pivots[i]] = pivotValues[i]
129 | }
130 | for (const measure of measures) {
131 | const cell = row[measure.name][flatKey] as Cell
132 | const cellValue = htmlForCell(cell)
133 | pivotRow[measure.name] = cellValue
134 | }
135 | }
136 | ptData.push(pivotRow)
137 | }
138 | }
139 | }
140 |
141 | // We create our own aggregators instead of using
142 | // $.pivotUtilities.aggregators because we want to use our own configurable
143 | // number formatter for some of them.
144 | const tpl = $.pivotUtilities.aggregatorTemplates
145 | const intFormat = formatType('###,###,###,##0')
146 |
147 | const aggregatorNames = []
148 | const aggregators = []
149 | for (let i = 0; i < measures.length; i++) {
150 | const { type, name, value_format, view_label: label1, label_short: label2 } = measures[i]
151 | const customFormat = formatType(value_format) || defaultFormatter
152 | let agg
153 | switch (type) {
154 | case 'count': agg = tpl.sum(intFormat); break
155 | case 'count_distinct': agg = tpl.sum(intFormat); break
156 | case 'sum': agg = tpl.sum(customFormat); break
157 | case 'sum_distinct': agg = tpl.sum(customFormat); break
158 | case 'average': agg = tpl.average(customFormat); break
159 | case 'median': agg = tpl.median(customFormat); break
160 | case 'min': agg = tpl.min(customFormat); break
161 | case 'max': agg = tpl.max(customFormat); break
162 | case 'list': agg = tpl.listUnique(', '); break
163 | case 'percent_of_total': agg = tpl.fractionOf(tpl.sum(), 'total', customFormat); break
164 | case 'int': agg = tpl.sum(intFormat); break
165 | case 'number': agg = tpl.sum(customFormat); break
166 | default:
167 | if (this && this.clearErrors && this.addError) {
168 | this.clearErrors('measure-type')
169 | this.addError({
170 | group: 'measure-type',
171 | title: `Cannot Show "${label1} ${label2}"`,
172 | message: `Measure types of '${type}' are unsupported by this visualization.`
173 | })
174 | }
175 | return
176 | }
177 | const aggName = `measure_${i}`
178 | labels[aggName] = config.show_full_field_name ? { label: label1, sublabel: label2 } : { label: label2 }
179 | aggregatorNames.push(aggName)
180 | aggregators.push(agg([name]))
181 | }
182 |
183 | const numericSortAsc = (a: any, b: any) => a - b
184 | const numericSortDesc = (a: any, b: any) => b - a
185 | const stringSortAsc = (a: any, b: any) => (
186 | a === LOOKER_ROW_TOTAL_KEY ? Infinity :
187 | b === LOOKER_ROW_TOTAL_KEY ? -Infinity :
188 | String(a).localeCompare(b)
189 | )
190 | const stringSortDesc = (a: any, b: any) => (
191 | a === LOOKER_ROW_TOTAL_KEY ? Infinity :
192 | b === LOOKER_ROW_TOTAL_KEY ? -Infinity :
193 | String(b).localeCompare(a)
194 | )
195 | const sorters: any = {}
196 | for (const fieldType of ['measure_like', 'dimension_like', 'pivots']) {
197 | for (const field of queryResponse.fields[fieldType]) {
198 | if (field.sorted != null) {
199 | if (field.is_numeric) {
200 | sorters[field.name] = field.sorted.desc ? numericSortDesc : numericSortAsc
201 | } else {
202 | sorters[field.name] = field.sorted.desc ? stringSortDesc : stringSortAsc
203 | }
204 | }
205 | }
206 | }
207 |
208 | const dataClass = $.pivotUtilities.SubtotalPivotDataMulti
209 | const renderer = $.pivotUtilities.subtotal_renderers['Table With Subtotal']
210 | const rendererOptions = {
211 | arrowExpanded: '▼',
212 | arrowCollapsed: '▶'
213 | }
214 |
215 | const options = {
216 | rows: dimensions,
217 | cols: pivots,
218 | labels,
219 | dataClass,
220 | renderer,
221 | rendererOptions,
222 | aggregatorNames,
223 | aggregators,
224 | sorters,
225 | hasColTotals: queryResponse.has_totals,
226 | hasRowTotals: queryResponse.has_row_totals
227 | }
228 | $(element).pivot(ptData, options)
229 | }
230 | }
231 |
232 | looker.plugins.visualizations.add(vis)
233 |
--------------------------------------------------------------------------------
/src/examples/subtotal/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'subtotal-multiple-aggregates' {
2 | const fn: PivotModule
3 | export default fn
4 | }
5 |
6 | interface PivotModule {
7 | ($: JQueryStatic): any
8 | }
9 |
10 | interface JQuery {
11 | pivot(data: any, options: any): JQuery
12 | }
13 |
14 | interface JQueryStatic {
15 | pivotUtilities: any
16 | }
17 |
--------------------------------------------------------------------------------
/src/examples/sunburst/README.md:
--------------------------------------------------------------------------------
1 | # Sunburst
2 |
3 | 
4 |
5 | This diagram creates a [sunburst](https://en.wikipedia.org/wiki/Pie_chart#Ring_chart_.2F_Sunburst_chart_.2F_Multilevel_pie_chart) to display hierarchical data in a nested structure.
6 |
7 | 
8 |
9 | **Implementation Instructions**
10 | Follow the instructions in [Looker's documentation](https://docs.looker.com/admin-options/platform/visualizations). Note that this viz does not require an SRI hash and has no dependencies. Simply create a unique ID, a label for the viz, and paste in the CDN link below.
11 |
12 | **CDN Link**
13 |
14 | Paste the following URL into the "Main" section of your Admin/Visualization page.
15 |
16 | https://looker-custom-viz-a.lookercdn.com/master/sunburst.js
17 |
18 | **How it works**
19 |
20 | Create a Look with any number of dimensions and exactly one measure.
21 |
22 | For example, in the sunburst featured above, you can see event counts by the hierarchical sequence of events.
23 |
24 | **More Info**
25 |
26 | The sunburst chart is represented by one or more complete and partial circles, or rings. The inner-most ring is always complete. Subsequent rings can be complete or fragmented, depending on the presence of data within the first rings’ categories. For example, the first ring may have three categories: completed orders, returned orders, and incomplete orders. On a given day the second ring may include data for both the returned and completed orders, but no data for incomplete orders. The second ring would then be fragmented, represented by a missing section over the first part of the ring representing incomplete orders.
27 |
28 | The sunburst visualization is meant to display data across two or more dimensions. Similar to a collapsible tree, it is best used when comparing data across increasing levels of granularity. There is no limit to the number of dimensions that can be used, but the graph becomes difficult to understand and trace with an overabundance of dimensions. The same issues arise with overly granular data. One category in the first ring that has 50 subcategories associated with it will be difficult to read.
29 |
--------------------------------------------------------------------------------
/src/examples/sunburst/sunburst.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/sunburst/sunburst.mov
--------------------------------------------------------------------------------
/src/examples/sunburst/sunburst.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/sunburst/sunburst.png
--------------------------------------------------------------------------------
/src/examples/sunburst/sunburst.ts:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import { formatType, handleErrors } from '../common/utils'
3 |
4 | import {
5 | Link,
6 | Looker,
7 | LookerChartUtils,
8 | Row,
9 | VisConfig,
10 | VisualizationDefinition
11 | } from '../types/types'
12 |
13 | // Global values provided via the API
14 | declare var looker: Looker
15 | declare var LookerCharts: LookerChartUtils
16 |
17 | const colorBy = {
18 | NODE: 'node',
19 | ROOT: 'root'
20 | }
21 |
22 | interface SunburstVisualization extends VisualizationDefinition {
23 | svg?: any,
24 | }
25 |
26 | // recursively create children array
27 | function descend(obj: any, depth: number = 0) {
28 | const arr: any[] = []
29 | for (const k in obj) {
30 | if (k === '__data') {
31 | continue
32 | }
33 | const child: any = {
34 | name: k,
35 | depth,
36 | children: descend(obj[k], depth + 1)
37 | }
38 | if ('__data' in obj[k]) {
39 | child.data = obj[k].__data
40 | child.links = obj[k].__data.taxonomy.links
41 | }
42 | arr.push(child)
43 | }
44 | return arr
45 | }
46 |
47 | function burrow(table: Row[], config: VisConfig) {
48 | // create nested object
49 | const obj: any = {}
50 |
51 | table.forEach((row: Row) => {
52 | // start at root
53 | let layer = obj
54 |
55 | // create children as nested objects
56 | row.taxonomy.value.forEach((key: any) => {
57 | if (key === null && !config.show_null_points) {
58 | return
59 | }
60 | layer[key] = key in layer ? layer[key] : {}
61 | layer = layer[key]
62 | })
63 | layer.__data = row
64 | })
65 |
66 | // use descend to create nested children arrays
67 | return {
68 | name: 'root',
69 | children: descend(obj, 1),
70 | depth: 0
71 | }
72 | }
73 |
74 | const getLinksFromRow = (row: Row): Link[] => {
75 | return Object.keys(row).reduce((links: Link[], datum) => {
76 | if (row[datum].links) {
77 | const datumLinks = row[datum].links as Link[]
78 | return links.concat(datumLinks)
79 | } else {
80 | return links
81 | }
82 | }, [])
83 | }
84 |
85 | const vis: SunburstVisualization = {
86 | id: 'sunburst', // id/label not required, but nice for testing and keeping manifests in sync
87 | label: 'Sunburst',
88 | options: {
89 | color_range: {
90 | type: 'array',
91 | label: 'Color Range',
92 | display: 'colors',
93 | default: ['#dd3333', '#80ce5d', '#f78131', '#369dc1', '#c572d3', '#36c1b3', '#b57052', '#ed69af']
94 | },
95 | color_by: {
96 | type: 'string',
97 | label: 'Color By',
98 | display: 'select',
99 | values: [
100 | { 'Color By Root': colorBy.ROOT },
101 | { 'Color By Node': colorBy.NODE }
102 | ],
103 | default: colorBy.ROOT
104 | },
105 | show_null_points: {
106 | type: 'boolean',
107 | label: 'Plot Null Values',
108 | default: true
109 | }
110 | },
111 | // Set up the initial state of the visualization
112 | create(element, _config) {
113 | element.style.fontFamily = `"Open Sans", "Helvetica", sans-serif`
114 | this.svg = d3.select(element).append('svg')
115 | },
116 | // Render in response to the data or settings changing
117 | update(data, element, config, queryResponse) {
118 | if (!handleErrors(this, queryResponse, {
119 | min_pivots: 0, max_pivots: 0,
120 | min_dimensions: 1, max_dimensions: undefined,
121 | min_measures: 1, max_measures: 1
122 | })) return
123 |
124 | const width = element.clientWidth
125 | const height = element.clientHeight
126 | const radius = Math.min(width, height) / 2 - 8
127 |
128 | const dimensions = queryResponse.fields.dimension_like
129 | const measure = queryResponse.fields.measure_like[0]
130 | const format = formatType(measure.value_format) || ((s: any): string => s.toString())
131 |
132 | const colorScale: d3.ScaleOrdinal = d3.scaleOrdinal()
133 | const color = colorScale.range(config.color_range || [])
134 |
135 | data.forEach(row => {
136 | row.taxonomy = {
137 | links: getLinksFromRow(row),
138 | value: dimensions.map((dimension) => row[dimension.name].value)
139 | }
140 | })
141 |
142 | const partition = d3.partition().size([2 * Math.PI, radius * radius])
143 |
144 | const arc = (
145 | d3.arc()
146 | .startAngle((d: any) => d.x0)
147 | .endAngle((d: any) => d.x1)
148 | .innerRadius((d: any) => Math.sqrt(d.y0))
149 | .outerRadius((d: any) => Math.sqrt(d.y1))
150 | )
151 |
152 | const svg = (
153 | this.svg
154 | .html('')
155 | .attr('width', '100%')
156 | .attr('height', '100%')
157 | .append('g')
158 | .attr('transform', 'translate(' + width / 2 + ',' + height / 2 + ')')
159 | )
160 |
161 | const label = svg.append('text').attr('y', -height / 2 + 20).attr('x', -width / 2 + 20)
162 |
163 | const root = d3.hierarchy(burrow(data, config)).sum((d: any) => {
164 | return 'data' in d ? d.data[measure.name].value : 0
165 | })
166 | partition(root)
167 |
168 | svg
169 | .selectAll('path')
170 | .data(root.descendants())
171 | .enter()
172 | .append('path')
173 | .attr('d', arc)
174 | .style('fill', (d: any) => {
175 | if (d.depth === 0) return 'none'
176 | if (config.color_by === colorBy.NODE) {
177 | return color(d.data.name)
178 | } else {
179 | return color(d.ancestors().map((p: any) => p.data.name).slice(-2, -1))
180 | }
181 | })
182 | .style('fill-opacity', (d: any) => 1 - d.depth * 0.15)
183 | .style('transition', (d: any) => 'fill-opacity 0.5s')
184 | .style('stroke', (d: any) => '#fff')
185 | .style('stroke-width', (d: any) => '0.5px')
186 | .on('click', function (this: any, d: any) {
187 | const event: object = { pageX: d3.event.pageX, pageY: d3.event.pageY }
188 | LookerCharts.Utils.openDrillMenu({
189 | links: d.data.links,
190 | event: event
191 | })
192 | })
193 | .on('mouseenter', (d: any) => {
194 | const ancestorText = (
195 | d.ancestors()
196 | .map((p: any) => p.data.name)
197 | .slice(0, -1)
198 | .reverse()
199 | .join('-')
200 | )
201 | label.text(`${ancestorText}: ${format(d.value)}`)
202 |
203 | const ancestors = d.ancestors()
204 | svg
205 | .selectAll('path')
206 | .style('fill-opacity', (p: any) => {
207 | return ancestors.indexOf(p) > -1 ? 1 : 0.15
208 | })
209 | })
210 | .on('mouseleave', (d: any) => {
211 | label.text('')
212 | svg
213 | .selectAll('path')
214 | .style('fill-opacity', (d: any) => 1 - d.depth * 0.15)
215 | })
216 | }
217 | }
218 |
219 | looker.plugins.visualizations.add(vis)
220 |
--------------------------------------------------------------------------------
/src/examples/treemap/README.md:
--------------------------------------------------------------------------------
1 | # Treemap
2 |
3 | 
4 |
5 | This diagram creates a [treemap](https://en.wikipedia.org/wiki/Treemapping) to display hierarchical data in a nested structure.
6 |
7 | 
8 |
9 | **Implementation Instructions**
10 | Follow the instructions in [Looker's documentation](https://docs.looker.com/admin-options/platform/visualizations). Note that this viz does not require an SRI hash and has no dependencies. Simply create a unique ID, a label for the viz, and paste in the CDN link below.
11 |
12 | **CDN Link**
13 |
14 | Paste the following URL into the "Main" section of your Admin/Visualization page.
15 |
16 | https://looker-custom-viz-a.lookercdn.com/master/treemap.js
17 |
18 | **How it works**
19 |
20 | Create a Look with any amount of dimensions and one measure.
21 |
22 | For example, in the treemap featured above, you can see event counts by the hierarchical sequence of events.
23 |
24 | **More Info**
25 |
26 | The treemap will display categories of data, sized according to the proportion of the measure belonging to each category. From left to right, the chart will nest each dimension included in the query within the previous. It is best used in cases with dimensions that increase in granularity from left to right.
27 |
28 | A good example use case for this chart is an inventory report. A high level category (Dimension) could be "Department", followed by "Product Type", and again followed by "Sku Number". This sequence follows a one-to-many relationship between each dimension, moving from left to right (e.g. there are many product types within one department). Using this pattern, the chart will show large rectangles for the Department, and smaller nested rectangles for Product Type. Finally, the individual Sku numbers and their subsequent count will be nested within each Product Type's rectangle.
29 |
--------------------------------------------------------------------------------
/src/examples/treemap/treemap.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/treemap/treemap.mov
--------------------------------------------------------------------------------
/src/examples/treemap/treemap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/looker-open-source/custom_visualizations_v2/dc01e78a6780fb467cdc13701be748696712a083/src/examples/treemap/treemap.png
--------------------------------------------------------------------------------
/src/examples/treemap/treemap.ts:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import { formatType, handleErrors } from '../common/utils'
3 |
4 | import {
5 | Row,
6 | Looker,
7 | VisualizationDefinition
8 | } from '../types/types'
9 |
10 | // Global values provided via the API
11 | declare var looker: Looker
12 |
13 | interface TreemapVisualization extends VisualizationDefinition {
14 | svg?: d3.Selection,
15 | }
16 |
17 | // recursively create children array
18 | function descend(obj: any, depth: number = 0) {
19 | const arr: any[] = []
20 | for (const k in obj) {
21 | if (k === '__data') {
22 | continue
23 | }
24 | const child: any = {
25 | name: k,
26 | depth,
27 | children: descend(obj[k], depth + 1)
28 | }
29 | if ('__data' in obj[k]) {
30 | child.data = obj[k].__data
31 | }
32 | arr.push(child)
33 | }
34 | return arr
35 | }
36 |
37 | function burrow(table: Row[]) {
38 | // create nested object
39 | const obj: any = {}
40 |
41 | table.forEach((row: Row) => {
42 | // start at root
43 | let layer = obj
44 |
45 | // create children as nested objects
46 | row.taxonomy.value.forEach((key: any) => {
47 | layer[key] = key in layer ? layer[key] : {}
48 | layer = layer[key]
49 | })
50 | layer.__data = row
51 | })
52 |
53 | // use descend to create nested children arrays
54 | return {
55 | name: 'root',
56 | children: descend(obj, 1),
57 | depth: 0
58 | }
59 | }
60 |
61 | const vis: TreemapVisualization = {
62 | id: 'treemap',
63 | label: 'Treemap',
64 | options: {
65 | color_range: {
66 | type: 'array',
67 | label: 'Color Range',
68 | display: 'colors',
69 | default: ['#dd3333', '#80ce5d', '#f78131', '#369dc1', '#c572d3', '#36c1b3', '#b57052', '#ed69af']
70 | }
71 | },
72 | // Set up the initial state of the visualization
73 | create: function (element, config) {
74 | this.svg = d3.select(element).append('svg')
75 | },
76 | // Render in response to the data or settings changing
77 | update: function (data, element, config, queryResponse) {
78 | if (!handleErrors(this, queryResponse, {
79 | min_pivots: 0, max_pivots: 0,
80 | min_dimensions: 1, max_dimensions: undefined,
81 | min_measures: 1, max_measures: 1
82 | })) return
83 |
84 | const width = element.clientWidth
85 | const height = element.clientHeight
86 |
87 | const dimensions = queryResponse.fields.dimension_like
88 | const measure = queryResponse.fields.measure_like[0]
89 |
90 | const format = formatType(measure.value_format) || ((s: any): string => s.toString())
91 |
92 | const colorScale: d3.ScaleOrdinal = d3.scaleOrdinal()
93 | const color = colorScale.range(config.color_range)
94 |
95 | data.forEach((row: Row) => {
96 | row.taxonomy = {
97 | value: dimensions.map((dimension) => row[dimension.name].value)
98 | }
99 | })
100 |
101 | const treemap = d3.treemap()
102 | .size([width, height - 16])
103 | .tile(d3.treemapSquarify.ratio(1))
104 | .paddingOuter(1)
105 | .paddingTop((d) => {
106 | return d.depth === 1 ? 16 : 0
107 | })
108 | .paddingInner(1)
109 | .round(true)
110 |
111 | const svg = this.svg!
112 | .html('')
113 | .attr('width', '100%')
114 | .attr('height', '100%')
115 | .append('g')
116 | .attr('transform', 'translate(0,16)')
117 |
118 | const breadcrumb = svg.append('text')
119 | .attr('y', -5)
120 | .attr('x', 4)
121 |
122 | const root = d3.hierarchy(burrow(data)).sum((d: any) => {
123 | return 'data' in d ? d.data[measure.name].value : 0
124 | })
125 | treemap(root)
126 |
127 | const cell = svg.selectAll('.node')
128 | .data(root.descendants())
129 | .enter().append('g')
130 | .attr('transform', (d: any) => 'translate(' + d.x0 + ',' + d.y0 + ')')
131 | .attr('class', (d, i) => 'node depth-' + d.depth)
132 | .style('stroke-width', 1.5)
133 | .style('cursor', 'pointer')
134 | .on('click', (d) => console.log(d))
135 | .on('mouseenter', (d: any) => {
136 | const ancestors = d.ancestors()
137 | breadcrumb.text(
138 | ancestors.map((p: any) => p.data.name)
139 | .slice(0, -1)
140 | .reverse()
141 | .join('-') + ': ' + format(d.value)
142 | )
143 | svg.selectAll('g.node rect')
144 | .style('stroke', null)
145 | .filter((p: any) => ancestors.indexOf(p) > -1)
146 | .style('stroke', '#fff')
147 | })
148 | .on('mouseleave', (d) => {
149 | breadcrumb.text('')
150 | svg.selectAll('g.node rect')
151 | .style('stroke', (d) => {
152 | return null
153 | })
154 | })
155 |
156 | cell.append('rect')
157 | .attr('id', (d, i) => 'rect-' + i)
158 | .attr('width', (d: any) => d.x1 - d.x0)
159 | .attr('height', (d: any) => d.y1 - d.y0)
160 | .style('fill', (d: any) => {
161 | if (d.depth === 0) return 'none'
162 | const ancestor: string = d.ancestors().map((p: any) => p.data.name).slice(-2, -1)[0]
163 | const colors: any[] = [color(ancestor), '#ddd']
164 | const scale = d3.scaleLinear()
165 | .domain([1, 6.5])
166 | .range(colors)
167 | return scale(d.depth) ?? null;
168 | })
169 |
170 | cell.append('clipPath')
171 | .attr('id', (d, i) => 'clip-' + i)
172 | .append('use')
173 | .attr('xlink:href', (d, i) => '#rect-' + i)
174 |
175 | cell.append('text')
176 | .style('opacity', (d) => {
177 | if (d.depth === 1) return 1
178 | return 0
179 | })
180 | .attr('clip-path', (d, i) => 'url(#clip-' + i + ')')
181 | .attr('y', (d) => {
182 | return d.depth === 1 ? '13' : '10'
183 | })
184 | .attr('x', 2)
185 | .style('font-family', 'Helvetica, Arial, sans-serif')
186 | .style('fill', 'white')
187 | .style('font-size', (d) => {
188 | return d.depth === 1 ? '14px' : '10px'
189 | })
190 | .text((d) => d.data.name === 'root' ? '' : d.data.name)
191 |
192 | }
193 | }
194 |
195 | looker.plugins.visualizations.add(vis)
196 |
--------------------------------------------------------------------------------
/src/examples/types/types.ts:
--------------------------------------------------------------------------------
1 | // API Globals
2 | export interface Looker {
3 | plugins: {
4 | visualizations: {
5 | add: (visualization: VisualizationDefinition) => void
6 | }
7 | }
8 | }
9 |
10 | export interface LookerChartUtils {
11 | Utils: {
12 | openDrillMenu: (options: { links: Link[], event: object }) => void
13 | openUrl: (url: string, event: object) => void
14 | textForCell: (cell: Cell) => string
15 | filterableValueForCell: (cell: Cell) => string
16 | htmlForCell: (cell: Cell, context?: string, fieldDefinitionForCell?: any, customHtml?: string) => string
17 | }
18 | }
19 | /**
20 | * Minimal representation of a Crossfilter action
21 | */
22 | export interface Crossfilter {
23 | field: string
24 | values: string[]
25 | range?: [string, string]
26 | }
27 |
28 | // Looker visualization types
29 | export interface VisualizationDefinition {
30 | id?: string
31 | label?: string
32 | options: VisOptions
33 | addError?: (error: VisualizationError) => void
34 | clearErrors?: (errorName?: string) => void
35 | create: (element: HTMLElement, settings: VisConfig) => void
36 | onCrossfilter?: (crossfilters: Crossfilter[], event: Event | null) => void,
37 | trigger?: (event: string, config: object[]) => void
38 | update?: (data: VisData, element: HTMLElement, config: VisConfig, queryResponse: VisQueryResponse, details?: VisUpdateDetails) => void
39 | updateAsync?: (data: VisData, element: HTMLElement, config: VisConfig, queryResponse: VisQueryResponse, details: VisUpdateDetails | undefined, updateComplete: () => void) => void
40 | destroy?: () => void
41 | }
42 |
43 | export interface VisOptions { [optionName: string]: VisOption }
44 |
45 | export interface VisOptionValue { [label: string]: string }
46 |
47 | export interface VisQueryResponse {
48 | [key: string]: any
49 | data: VisData
50 | fields: {
51 | [key: string]: any[]
52 | }
53 | pivots: Pivot[]
54 | }
55 |
56 | export interface Pivot {
57 | key: string
58 | is_total: boolean
59 | data: { [key: string]: string }
60 | metadata: { [key: string]: { [key: string]: string } }
61 | }
62 |
63 | export interface Link {
64 | label: string
65 | type: string
66 | type_label: string
67 | url: string
68 | }
69 |
70 | export interface Cell {
71 | [key: string]: any
72 | value: any
73 | rendered?: string
74 | html?: string
75 | links?: Link[]
76 | }
77 |
78 | export interface FilterData {
79 | add: string
80 | field: string
81 | rendered: string
82 | }
83 |
84 | export interface PivotCell {
85 | [pivotKey: string]: Cell
86 | }
87 |
88 | export interface Row {
89 | [fieldName: string]: PivotCell | Cell
90 | }
91 |
92 | export type VisData = Row[]
93 |
94 | export interface VisConfig {
95 | [key: string]: VisConfigValue
96 | }
97 |
98 | export type VisConfigValue = any
99 |
100 | export interface VisUpdateDetails {
101 | changed: {
102 | config?: string[]
103 | data?: boolean
104 | queryResponse?: boolean
105 | size?: boolean
106 | }
107 | }
108 |
109 | export interface VisOption {
110 | type: string,
111 | values?: VisOptionValue[],
112 | display?: string,
113 | default?: any,
114 | label: string,
115 | section?: string,
116 | placeholder?: string,
117 | display_size?: 'half' | 'third' | 'normal'
118 | order?: number
119 | min?: number
120 | max?: number
121 | step?: number
122 | required?: boolean
123 | supports?: string[]
124 | }
125 |
126 | export interface VisualizationError {
127 | group?: string
128 | message?: string
129 | title?: string
130 | retryable?: boolean
131 | warning?: boolean
132 | }
133 |
--------------------------------------------------------------------------------
/test/looker_stub.js:
--------------------------------------------------------------------------------
1 | var allVisualizations = [];
2 |
3 | module.exports = {
4 | plugins: {
5 | visualizations: {
6 | add: function(vis) {
7 | allVisualizations.push(vis);
8 | },
9 | all: function() {
10 | return allVisualizations;
11 | }
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | // global scope of vis
2 | global.looker = require("./looker_stub")
3 |
4 | // Fix jQuery for anything that needs it
5 | var jsdom = require("jsdom")
6 | global.window = new jsdom.JSDOM().window
7 | global.$ = require("jquery")(global.window)
8 |
9 | // test deps
10 | var glob = require("glob")
11 | var path = require("path")
12 | var assert = require("assert")
13 |
14 | // Require all visualizations
15 | glob.sync("./dist/*.js").forEach((file) => {
16 | require(path.resolve(file))
17 | })
18 |
19 | looker.plugins.visualizations.all().forEach((vis) => {
20 | describe(`${vis.label} (as ${vis.id})`, () => {
21 |
22 | test("should load and not use unavailable things", () => {
23 | // TODO what do we need to assert here
24 | assert(true)
25 | });
26 |
27 | test("should have a valid ID", () => {
28 | assert(/^\w+$/.test(vis.id))
29 | })
30 |
31 | test("should have a label", () => {
32 | assert(/\S/.test(vis.label))
33 | })
34 |
35 | test("should implement create", () => {
36 | assert(typeof vis.create === "function")
37 | })
38 |
39 | test("should implement update or updateAsync", () => {
40 | assert(typeof vis.update === "function" || typeof vis.updateAsync === "function")
41 | })
42 |
43 | })
44 | })
45 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "es6",
4 | "moduleResolution": "node",
5 | "noImplicitReturns": true,
6 | "outDir": "lib",
7 | "strict": true,
8 | "lib": [ "dom", "es6", "es2015.collection" ],
9 | "target": "es5"
10 | },
11 | "exclude": [
12 | "node_modules"
13 | ],
14 | "rootDir": [
15 | "src/"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": [
4 | "tslint-config-standard"
5 | ],
6 | "jsRules": {},
7 | "rules": {
8 | "no-namespace": [
9 | true,
10 | "allow-declarations"
11 | ],
12 | "prefer-const": [
13 | true,
14 | {
15 | "destructuring": "all"
16 | }
17 | ],
18 | "no-reference": true,
19 | "promise-function-async": true,
20 | "await-promise": true,
21 | "no-inferred-empty-object-type": true,
22 | "space-before-function-paren": false,
23 | "strict-type-predicates": true,
24 | "return-undefined": true
25 | },
26 | "rulesDirectory": []
27 | }
28 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path')
2 |
3 | const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
4 |
5 | var webpackConfig = {
6 | mode: 'production',
7 | entry: {
8 | advanced_table: './src/examples/advanced_table/advanced_table.js',
9 | v1_common: './src/common/common-entry.js',
10 | hello_world: './src/examples/hello_world/hello_world.js',
11 | hello_world_react: './src/examples/hello_world_react/hello_world_react.js',
12 | sankey: './src/examples/sankey/sankey.ts',
13 | liquid_fill_gauge: './src/examples/liquid_fill_gauge/liquid_fill_gauge.ts',
14 | sunburst: './src/examples/sunburst/sunburst.ts',
15 | collapsible_tree: './src/examples/collapsible_tree/collapsible_tree.ts',
16 | chord: './src/examples/chord/chord.ts',
17 | treemap: './src/examples/treemap/treemap.ts',
18 | subtotal: './src/examples/subtotal/subtotal.ts',
19 | image_carousel: './src/examples/image_carousel/image_carousel.js'
20 | },
21 | output: {
22 | filename: "[name].js",
23 | path: path.join(__dirname, "dist"),
24 | library: "[name]",
25 | libraryTarget: "umd"
26 | },
27 | resolve: {
28 | extensions: [".ts", ".js"]
29 | },
30 | plugins: [
31 | new UglifyJSPlugin()
32 | ],
33 | module: {
34 | rules: [
35 | { test: /\.js$/, loader: "babel-loader" },
36 | { test: /\.ts$/, loader: "ts-loader" },
37 | { test: /\.css$/, loader: [ 'to-string-loader', 'css-loader' ] }
38 | ]
39 | },
40 | stats: {
41 | warningsFilter: /export.*liquidfillgauge.*was not found/
42 | },
43 | performance: {
44 | hints: false,
45 | maxEntrypointSize: 512000,
46 | maxAssetSize: 512000
47 | }
48 | }
49 |
50 | module.exports = webpackConfig
51 |
--------------------------------------------------------------------------------