├── .babelrc ├── .github └── CODEOWNERS ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dist ├── advanced_table.js ├── aster_plot.js ├── bubble_chart.js ├── calendar_chart.js ├── cartoon.js ├── chord.js ├── collapsible_tree.js ├── gauge_chart.js ├── grouped_card.js ├── hello_world.js ├── hello_world_react.js ├── image_carousel.js ├── liquid_fill_gauge.js ├── multiple_gauge_charts.js ├── sales_heatmap.js ├── sales_waterfall.js ├── sankey.js ├── spider.js ├── subtotal.js ├── sunburst.js ├── treemap.js └── v1_common.js ├── docs ├── api_reference.md ├── custom_color_palette.md └── getting_started.md ├── package.json ├── src ├── common │ ├── common-entry.js │ └── d3.v4.js └── examples │ ├── README.md │ ├── advanced_table │ ├── README.md │ ├── advanced_table.js │ ├── ag-theme-looker.css │ ├── globalConfig.js │ ├── options.js │ └── pivotHeader.js │ ├── chord │ ├── README.md │ ├── chord.mov │ ├── chord.png │ └── chord.ts │ ├── collapsible_tree │ ├── README.md │ ├── collapsible-tree.mov │ ├── collapsible-tree.png │ └── collapsible_tree.ts │ ├── common │ └── utils.ts │ ├── hello_world │ ├── hello_world.js │ ├── hello_world.png │ └── hello_world_error.png │ ├── hello_world_react │ ├── hello.js │ ├── hello_world.png │ ├── hello_world_error.png │ └── hello_world_react.js │ ├── image_carousel │ ├── README.md │ ├── c3_image_carousel.png │ ├── constants.js │ ├── imageViewer.js │ └── image_carousel.js │ ├── liquid_fill_gauge │ ├── README.md │ ├── liquid_fill_gauge.js │ ├── liquid_fill_gauge.mov │ ├── liquid_fill_gauge.png │ └── liquid_fill_gauge.ts │ ├── sankey │ ├── README.md │ ├── sankey.mov │ ├── sankey.png │ └── sankey.ts │ ├── subtotal │ ├── README.md │ ├── subtotal.png │ ├── subtotal.ts │ └── types.d.ts │ ├── sunburst │ ├── README.md │ ├── sunburst.mov │ ├── sunburst.png │ └── sunburst.ts │ ├── treemap │ ├── README.md │ ├── treemap.mov │ ├── treemap.png │ └── treemap.ts │ └── types │ └── types.ts ├── test ├── looker_stub.js └── test.js ├── tsconfig.json ├── tslint.json ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @looker-open-source/cloud-looker-devrel 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | demo/build 3 | node_modules 4 | .DS_Store 5 | .vscode 6 | yarn-error.log 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement (CLA). You (or your employer) retain the copyright to your 10 | contribution; this simply gives us permission to use and redistribute your 11 | contributions as part of the project. Head over to 12 | to see your current agreements on file or 13 | to sign a new one. 14 | 15 | You generally only need to submit a CLA once, so if you've already submitted one 16 | (even if it was for a different project), you probably don't need to do it 17 | again. 18 | 19 | ## Code Reviews 20 | 21 | All submissions, including submissions by project members, require review. We 22 | use GitHub pull requests for this purpose. Consult 23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 24 | information on using pull requests. 25 | 26 | ## Community Guidelines 27 | 28 | This project follows 29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Looker Data Sciences, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **A Note on Support** 2 | 3 | The visualizations provided in this repository are intended to serve as examples. Looker's support team does not troubleshoot issues relating to these example visualizations or your custom visualization code. Supported visualizations are downloadable through the [Looker Marketplace](https://docs.looker.com/data-modeling/marketplace). 4 | 5 | ---- 6 | 7 | # Looker Visualization API Examples [![Build Status](https://travis-ci.org/looker/visualization-api-examples.svg?branch=master)](https://travis-ci.org/looker/visualization-api-examples) 8 | 9 | [Looker](https://looker.com/)'s Visualization API provides a simple JavaScript interface to creating powerful, customizable visualizations that seamlessly integrate into your data applications. :bar_chart: :chart_with_downwards_trend: :chart_with_upwards_trend: 10 | 11 | ### [Getting Started Guide →](docs/getting_started.md) 12 | 13 | ### [Visualization API Reference →](docs/api_reference.md) 14 | 15 | ### [View Examples Library →](src/examples) 16 | 17 | # Getting Started 18 | 19 | 1. [Ensure you have Yarn installed.](https://yarnpkg.com) 20 | 2. Run `yarn` 21 | 3. :boom: start creating! 22 | 23 | # Commands 24 | 25 | * `yarn build` - Compiles the code in `/src` to `/dist` via webpack 26 | * `yarn lint` - Runs TSLint across the codebase. 27 | * `yarn lint-fix` - Runs TSLint and attempts to fix any linter errors automatically. 28 | 29 | 30 | ---- 31 | 32 | 33 | -------------------------------------------------------------------------------- /dist/aster_plot.js: -------------------------------------------------------------------------------- 1 | looker.plugins.visualizations.add({options:{legend:{section:"Data",type:"string",label:"Legend",values:[{Left:"left"},{Right:"right"},{Off:"off"}],display:"radio",default:"off"},label_value:{section:"Data",type:"string",label:"Data Labels",values:[{On:"on"},{Off:"off"}],display:"radio",default:"on"},center_value:{section:"Data",type:"string",label:"Center Value",values:[{"Weighted Avg":"avg"},{Min:"min"},{Max:"max"},{None:"off"}],display:"select",default:"avg"},color_range:{section:"Format",order:1,type:"array",label:"Color Range",display:"colors",default:["#9E0041","#C32F4B","#E1514B","#F47245","#FB9F59","#FEC574","#FAE38C","#EAF195","#C7E89E","#9CD6A4","#6CC4A4","#4D9DB4","#4776B4","#5E4EA1"]},inner_circle_color:{section:"Format",order:2,type:"string",label:"Inner Circle",display:"color",default:"#ffffff"},text_color:{section:"Format",order:3,type:"string",label:"Text Color",display:"color",default:"#000000"},font_size:{section:"Format",order:4,type:"number",label:"Font Size",display:"range",min:10,max:100,default:40},radius:{section:"Data",type:"number",label:"Range Override",placeholder:"Value represented by radius of circle"},threshold:{section:"Format",type:"number",label:"Label Minimum Slice Size (radians)",default:.2},label_size:{section:"Format",type:"number",label:"Label Font Size (px)",default:10},chart_size:{section:"Format",order:4,type:"string",label:"Chart Size",default:"100%"}},create:function(e,t){e.innerHTML='\n ';var n=e.appendChild(document.createElement("div"));this.container=n,n.className="d3-aster-plot",this._textElement=n.appendChild(document.createElement("div"))},updateAsync:function(e,t,n,a,r,l){if(this.container.innerHTML="",this.clearErrors(),i=this,o={min_pivots:0,max_pivots:0,min_dimensions:1,max_dimensions:1,min_measures:2,max_measures:2},s=function(e,t,n,a,r){return!(!i.addError||!i.clearErrors)&&(nr?(i.addError({title:"Too Many "+t+"s",message:"This visualization requires "+(a===r?"exactly":"no more than")+" "+r+" "+t.toLowerCase()+(1===a?"":"s")+".",group:e}),!1):(i.clearErrors(e),!0))},d=a.fields,c=d.pivots,u=d.dimension_like,f=d.measure_like,s("pivot-req","Pivot",c.length,o.min_pivots,o.max_pivots)&&s("dim-req","Dimension",u.length,o.min_dimensions,o.max_dimensions)&&s("mes-req","Measure",f.length,o.min_measures,o.max_measures)){var i,o,s,d,c,u,f,p=a.fields.dimension_like[0].name,g=a.fields.measure_like[0].name,h=a.fields.measure_like[1].name,m=30,x=30,y=30,v=30,_=t.clientWidth-v-x,b=t.clientHeight-m-y,A=Math.min(_,b)/2,E=.3*A;if(!isNaN(parseFloat(n.chart_size))){var k=parseFloat(n.chart_size)/100;A*=k>2?2:k<.2?.2:k}n.color_range||(n.color_range=["#9E0041","#C32F4B","#E1514B","#F47245","#FB9F59","#FEC574","#FAE38C","#EAF195","#C7E89E","#9CD6A4","#6CC4A4","#4D9DB4","#4776B4","#5E4EA1"]);var C=[],z=[],F=n.color_range.length,w={};for(let t=0;t=F){let a=Math.floor(t/F);e[t].color=n.color_range[t-a*F]}else e[t].color=n.color_range[t];e[t].label=e[t][p].value,e[t].score=+e[t][g].value,e[t].weight=+e[t][h].value,e[t].width=+e[t][h].value,e[t].rendered=e[t][g].rendered?e[t][g].rendered:e[t][g].value,C.push(e[t][g].value),z.push(e[t][h].value),w[e[t][p].value]=e[t][g].rendered?e[t][g].rendered:e[t][g].value}if(n.radius?console.log("Radius config set to: "+n.radius):(console.log("Radius not set. Defaulting to max score: "+V(C)),n.radius=V(C)),n.keyword_search){for(let t=0;t"+e.data.rendered+""}),N=d3.svg.arc().innerRadius(E).outerRadius(function(e){return(A-E)*(e.data.score/(1*n.radius))+E}),S=d3.svg.arc().innerRadius(E).outerRadius(A),T=d3.select(".d3-aster-plot").append("svg").attr("width",_+v+x).attr("height",b+m+y).append("g").attr("transform","translate("+(_/2+v)+","+(b/2+m)+")");T.call(R);T.append("circle").attr("cx",0).attr("cy",0).attr("r",E).attr("fill",n.inner_circle_color),T.append("svg:text").attr("class","aster-score").attr("dy",".35em").attr("text-anchor","middle").style("fill",n.text_color).attr("font-size",n.font_size).text(()=>{if("off"!=n.center_value)return"avg"==n.center_value?B:"min"==n.center_value?D:"max"==n.center_value?L:void 0});T.append("text").attr("class","score-sublabel").attr("dy","2em").attr("text-anchor","middle").style("fill","#282828").attr("font-size",.4*n.font_size).text(()=>{if("off"!=n.center_value)return"avg"==n.center_value?"AVG":"min"==n.center_value?"MIN":"max"==n.center_value?"MAX":void 0});T.selectAll(".solidArc").data(O(e)).enter().append("path").attr("data-legend",function(e){return e.data.label}).attr("fill",function(e){return e.data.color}).attr("class","solidArc").attr("stroke","gray").attr("d",N).on("mouseover",R.show).on("mouseout",R.hide),T.selectAll(".outlineArc").data(O(e)).enter().append("path").attr("fill","none").attr("stroke","gray").attr("class","outlineArc").attr("d",S).each(function(e,t){var n=/(^.+?)L/.exec(d3.select(this).attr("d"))[1];if(n=n.replace(/,/g," "),q(e.startAngle,e.endAngle)){var a=/0 0 1 (.*?)$/.exec(n)[1],r=/M(.*?)A/.exec(n)[1];n="M"+a+"A"+/A(.*?)0 0 1/.exec(n)[1]+"0 0 0 "+r}T.append("path").attr("class","hiddenDonutArcs").attr("id","sliceOutlineArc_"+t).attr("d",n).style("fill","none")});if("on"==n.label_value&&(T.selectAll(".label-line-1").data(O(e)).enter().append("text").attr("class","label-line-1").attr("dy",function(e,t){return q(e.startAngle,e.endAngle)?18:-21}).append("textPath").attr("startOffset","50%").style("text-anchor","middle").attr("xlink:href",function(e,t){return"#sliceOutlineArc_"+t}).attr("font-size",n.label_size).text(function(e){if(e.endAngle-e.startAngle>n.threshold)return e.data.label}),T.selectAll(".label-line-2").data(O(e)).enter().append("text").attr("class","label-line-2").attr("dy",function(e,t){return q(e.startAngle,e.endAngle)?28:-11}).append("textPath").attr("startOffset","50%").style("text-anchor","middle").attr("font-size",n.label_size).attr("xlink:href",function(e,t){return"#sliceOutlineArc_"+t}).text(function(e){if(e.endAngle-e.startAngle>n.threshold)return e.data.rendered})),"left"==n.legend)T.append("g").attr("class","legend").attr("transform","translate(-"+_/2.2+" ,-"+b/2.5+")").style("font-size","12px").call(H);else if("right"==n.legend)T.append("g").attr("class","legend").attr("transform","translate("+_/3+" ,-"+b/2.5+")").style("font-size","12px").call(H);l()}function q(e,t){return P(t)>90&&P(t)<270&&P(t-e)<180}function P(e){return 180*e/Math.PI}function V(e){return Math.max.apply(null,e)}function H(e){return e.each(function(){var e=d3.select(this),t={},n=d3.select(e.property("nearestViewportElement")),a=e.attr("data-style-padding")||5,r=e.selectAll(".legend-box").data([!0]),l=e.selectAll(".legend-items").data([!0]);r.enter().append("rect").classed("legend-box",!0),l.enter().append("g").classed("legend-items",!0),n.selectAll("[data-legend]").each(function(){var e=d3.select(this);t[e.attr("data-legend")]={pos:e.attr("data-legend-pos")||this.getBBox().y,color:null!=e.attr("data-legend-color")?e.attr("data-legend-color"):"none"!=e.style("fill")?e.style("fill"):e.style("stroke"),rendered:"100"}}),t=d3.entries(t).sort(function(e,t){return e.keyt.key?1:0});for(let e=0;e0==s&&e<=t||s>0&&e<=t&&e>f[s-1])[0],l=t[o[3].name].value,a=s+"|"+_.filter((e,t)=>0==t&&l<=e||t>0&&l<=e&&l>_[t-1])[0],i=x.filter(e=>a in e);if(i.length>0)i[0][a]+=1;else{const e={};e[a]=1,x.push(e)}b++}const g=[];for(let e of x){const t=Object.keys(e)[0];g.includes(e[t])||g.push(e[t])}let w=d3.scaleBand().range([0,d]).domain(f).padding(0);u.append("g").attr("transform","translate(0,"+c+")").call(d3.axisBottom(w));let k=d3.scaleBand().range([c,0]).domain(_).padding(0);u.append("g").call(d3.axisLeft(k));const L=Math.min(0,d3.min(g)),C=d3.max(g),q=Math.round((L+C)/2),A=d3.scaleLinear().range(s.color_range.slice(0,3)).domain([L,q,C]),E=d3.select("#"+this.el_id).append("div").style("opacity",0).attr("class","tooltip").style("position","absolute").style("background-color","white").style("border","solid").style("border-width","2px").style("border-radius","5px").style("padding","5px");u.selectAll().data(x,e=>Object.keys(e)[0]).enter().append("rect").attr("x",e=>{const t=Object.keys(e)[0].split("|");return w(t[0])}).attr("y",e=>{const t=Object.keys(e)[0].split("|");return k(t[1])}).attr("width",w.bandwidth()).attr("height",k.bandwidth()).style("fill",e=>{const t=Object.keys(e)[0];return A(e[t])}).on("mouseover",function(e){E.style("opacity",1)}).on("mousemove",function(e){const t=d3.mouse(this),s=Object.keys(e)[0],l=Math.round(e[s]/b*100,2);E.html("The exact value of
this cell is: "+l+"%").style("left",t[0]+100+"px").style("top",t[1]+"px")}).on("mouseleave",function(e){E.style("opacity",0)});const O=[];for(let t of e)t[o[1].name].value.includes("Closed")||O.push(t);w=d3.scaleLinear().domain([0,Math.max(y,f[f.length-1])]).range([0,d]),k=d3.scaleLinear().domain([0,Math.max(v,_[_.length-1])]).range([c,0]);const M=d3.select("#"+this.el_id).append("div").style("opacity",0).attr("class","tooltip").style("position","absolute").style("background-color","white").style("border","solid").style("border-width","2px").style("border-radius","5px").style("padding","5px");u.append("g").selectAll("dot").data(O).enter().append("circle").attr("cx",function(e){return w(e[o[2].name].value)}).attr("cy",function(e){return k(e[o[3].name].value)}).attr("r",3).style("fill","#5ca6bb").on("mouseover",function(e){M.style("opacity",1)}).on("mousemove",function(e){const t=d3.mouse(this);M.html("Name: "+e[o[0].name].value+"
Age: "+e[o[2].name].value+"
ACV: "+e[o[3].name].value).style("left",t[0]+100+"px").style("top",t[1]+"px")}).on("mouseleave",function(e){M.style("opacity",0)})}}); -------------------------------------------------------------------------------- /dist/sales_waterfall.js: -------------------------------------------------------------------------------- 1 | function nFormatter(e){return(e=e>=0?e:Math.abs(e))>=1e9?(e/1e9).toFixed(1)+"B":e>=1e6?(e/1e6).toFixed(1)+"M":e>=1e3?(e/1e3).toFixed(1)+"K":e>0?e.toFixed(1):e}!function(){var e={id:"highcharts_waterfall",label:"Waterfall",create:function(e,l){e.innerHTML='\n \n
\n '},update:function(e,l,a,t){t.fields.measure_like;for(var i,n=0,r=[],o=t.fields.measure_like.length,s=0;s0?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 | ![](../src/examples/hello_world/hello_world.png) 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 |
68 |
Data Goes Here
69 |
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 | ![](../src/examples/hello_world/hello_world_error.png) 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 | ![](collapsible-tree.png) 4 | 5 | This diagram displays a [treemap](https://en.wikipedia.org/wiki/Tree_structure), showing a hierarchy of a series of dimensions. 6 | 7 | ![](collapsible-tree.mov) 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 | ![](liquid_fill_gauge.png) 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 | ![](liquid_fill_gauge.mov) 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 | ![](sankey.png) 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 | ![](sankey.mov) 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 | ![](subtotal.png) 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 | ![](sunburst.png) 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 | ![](sunburst.mov) 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 | ![](treemap.png) 4 | 5 | This diagram creates a [treemap](https://en.wikipedia.org/wiki/Treemapping) to display hierarchical data in a nested structure. 6 | 7 | ![](treemap.mov) 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 | --------------------------------------------------------------------------------