module powerbi.visuals { export interface ViewModel { value: number; color?: string; min?: number; max?: number; } export class Thermometer implements IVisual { public static capabilities: VisualCapabilities = { dataRoles: [ { name: 'Category', kind: powerbi.VisualDataRoleKind.Grouping, displayName: 'Time' }, { name: 'Y', kind: powerbi.VisualDataRoleKind.Measure, displayName: 'Temperature' }, ], dataViewMappings: [{ categorical: { categories: { for: { in: 'Category' }, dataReductionAlgorithm: { bottom: {} } }, values: { select: [{ for: { in: 'Y' } }], dataReductionAlgorithm: { bottom: {} } }, } }], objects: { general: { displayName: 'General', properties: { fill: { type: { fill: { solid: { color: true } } }, displayName: 'Fill' }, max: { type: { numeric: true }, displayName: 'Max' }, min: { type: { numeric: true }, displayName: 'Min' } }, } }, }; public static converter(dataView: DataView, colors: IDataColorPalette): ViewModel { var series = dataView.categorical.values; return { value: series[0].values[series[0].values.length-1]} } private svg: D3.Selection; private backCircle: D3.Selection; private backRect: D3.Selection; private fillCircle: D3.Selection; private fillRect: D3.Selection; private tempMarkings: D3.Selection; private text: D3.Selection; private data: ViewModel; private dataView: DataView; /** This is called once when the visual is initialially created */ public init(options: VisualInitOptions): void { var svg = this.svg = d3.select(options.element.get(0)) .append('svg') .classed('thermometer', true); var mainGroup = svg.append('g'); this.backRect = mainGroup.append('rect'); this.backCircle = mainGroup.append('circle'); this.fillRect = mainGroup.append('rect'); this.fillCircle = mainGroup.append('circle'); this.text = mainGroup.append('text'); this.tempMarkings = svg.append("g") .attr("class", "y axis"); } /** Update is called for data updates, resizes & formatting changes */ public update(options: VisualUpdateOptions) { if(!options.dataViews) return; window.console.log('has data') var dataView = this.dataView = options.dataViews[0]; this.data = Thermometer.converter(options.dataViews[0],null); this.data.max = Thermometer.getValue(dataView,'max',90); this.data.min = Thermometer.getValue(dataView,'min',28); var viewport = options.viewport; var height = viewport.height; var width = viewport.width; var duration = options.suppressAnimations?0: 1000; this.svg.attr({ 'height': height, 'width': width }); this.draw(width, height, duration); } public draw(width: number, height: number, duration: number) { var radius = height * 0.1; var padding = radius * 0.25; this.drawBack(width, height, radius); this.drawFill(width, height, radius, padding, duration); this.drawTicks(width,height,radius, padding); this.drawText(width, height, radius, padding); } public drawBack(width: number, height: number, radius: number){ var rectHeight = height - radius; var fill = 'D3C8B4'; this.backCircle .attr({ 'cx': width / 2, 'cy': rectHeight, 'r': radius }) .style({ 'fill': fill }); this.backRect .attr({ 'x': (width - radius) / 2, 'y': 0, 'width': radius, 'height': rectHeight }) .style({ 'fill': fill }) } public drawFill(width: number, height: number, radius: number, padding: number, duration: number) { var innerRadius = radius * 0.8; var fillWidth = innerRadius * 0.7; var ZeroValue = height - (radius * 2) - padding; var fill = Thermometer.getFill(this.dataView).solid.color; var min = this.data.min; var max = this.data.max; var value = this.data.value > max ? max : this.data.value; var percentage = (ZeroValue - padding) * ((value - min)/(max-min)) var rectHeight = height - radius; this.fillCircle.attr({ 'cx': width / 2, 'cy': rectHeight, 'r': innerRadius }).style({ 'fill': fill }); this.fillRect .style({ 'fill': fill }) .attr({ 'x': (width - fillWidth) / 2, 'width': fillWidth, }) .transition() .duration(duration) .attr({ 'y': ZeroValue - percentage, 'height': rectHeight - ZeroValue +percentage }) } private drawTicks(width: number, height: number, radius: number, padding: number){ var y = d3.scale.linear().range([height - (radius * 2) - padding, padding]); var yAxis = d3.svg.axis().scale(y).ticks(4).orient("right"); y.domain([this.data.min, this.data.max]).nice(); this.tempMarkings .attr("transform", "translate(" + ((width + radius) / 2 + (radius * 0.15)) + ",0)") .style({ 'font-size':(radius * 0.03) + 'em', 'font-family': 'Tahoma', 'stroke':'none', 'fill': '#333' }) .call(yAxis); this.tempMarkings.selectAll('.axis line, .axis path') .style({'stroke': '#333', 'fill': 'none'}); } private drawText(width: number, height: number, radius: number, padding: number){ this.text .text((this.data.value > this.data.max ? this.data.max : this.data.value)|0) .attr({ 'x': width / 2, y: height - radius, 'dy': '.35em' }) .style({ 'fill': 'white', 'text-anchor': 'middle', 'font-family': 'impact', 'font-size': (radius * 0.055) + 'em' }) } public enumerateObjectInstances(options: EnumerateVisualObjectInstancesOptions): VisualObjectInstance[] { var instances: VisualObjectInstance[] = []; var dataView = this.dataView; switch (options.objectName) { case 'general': var general: VisualObjectInstance = { objectName: 'general', displayName: 'General', selector: null, properties: { fill: Thermometer.getFill(dataView), max: Thermometer.getValue(dataView,'max',90), min: Thermometer.getValue(dataView,'min',28) } }; instances.push(general); break; } return instances; } private static getFill(dataView: DataView): Fill { if (dataView) { var objects = dataView.metadata.objects; if (objects) { var general = objects['general']; if (general) { var fill = general['fill']; if (fill) return fill; } } } return { solid: { color: '#C02942'} }; } private static getValue(dataView: DataView, key: string, defaultValue: number): number { if (dataView) { var objects = dataView.metadata.objects; if (objects) { var general = objects['general']; if (general) { var size = general[key]; if (size != null) return size; } } } return defaultValue; } } }