declare module D3 { export module Time { export interface Time { weekOfYear(x: any): any;//this is missin from d3.d.ts } } } module powerbi.visuals { export interface DateValue { date: Date; value: number; }; export interface CalendarViewModel { values: DateValue[]; }; export class CalendarVisual implements IVisual { public static capabilities: VisualCapabilities = { dataRoles: [ { name: 'Category', kind: VisualDataRoleKind.Grouping, }, { name: 'Y', kind: VisualDataRoleKind.Measure, }, ], dataViewMappings: [{ categorical: { categories: { for: { in: 'Category' }, }, values: { for: { in: 'Y' } }, rowCount: { preferred: { max: 2 } } }, }], dataPoint: { displayName: data.createDisplayNameGetter('Visual_DataPoint'), properties: { fill: { displayName: data.createDisplayNameGetter('Visual_Fill'), type: { fill: { solid: { color: true } } } }, } }, labels: { displayName: data.createDisplayNameGetter('Visual_DataPointsLabels'), properties: { show: { displayName: data.createDisplayNameGetter('Visual_Show'), type: { bool: true } }, color: { displayName: data.createDisplayNameGetter('Visual_LabelsFill'), type: { fill: { solid: { color: true } } } }, labelDisplayUnits: { displayName: data.createDisplayNameGetter('Visual_DisplayUnits'), type: { formatting: { labelDisplayUnits: true } } } } } }; private drawMonthPath = false; private drawLegend = false; private drawLabels = true; private width = 1016; private height = 144; private cellSize = 18; // cell size private element: HTMLElement; private rect: D3.Selection; constructor(cellSizeOpt?: number) { if (cellSizeOpt) { this.cellSize = cellSizeOpt; } } public init(options: VisualInitOptions) { this.element = options.element.get(0); } public update(options: VisualUpdateOptions) { d3.select(this.element).selectAll("*").remove(); var viewModel = this.convert(options.dataViews[0]); if (viewModel == null) return; var maxDomain = Math.max.apply(Math, viewModel.values.map((v) => { return v.value; }) ); this.draw(this.element, options.viewport.width, options.viewport.height, this.getYears(viewModel), maxDomain); this.apply(viewModel, maxDomain); } private draw(element, itemWidth: number, itemHeight: number, range: number[], maxDomain: number) { var format = d3.time.format("%Y-%m-%d"); var svg = d3.select(element).selectAll("svg") .data(range) .enter().append("svg") .attr("width", itemWidth) .attr("height", itemWidth / 7) .attr("viewBox", "0 0 " + this.width + " " + this.height) .append("g") .attr("transform", "translate(" + ((this.width - this.cellSize * 52) / 2) + "," + (this.height - this.cellSize * 7 - 1) + ")"); if (this.drawLabels) { var textGroup = svg.append("g").attr("fill", "#cccccc"); textGroup.append("text") .attr("transform", "translate(" + this.cellSize * -1.5 + "," + this.cellSize * 3.5 + ")rotate(-90)") .style("text-anchor", "middle") .text(function (d) { return d; }); textGroup.append("text") .style("text-anchor", "middle") .text("M") .attr("transform", "translate(" + this.cellSize * -0.75 + ")") .attr("x", 0) .attr("y", 2 * this.cellSize); textGroup.append("text") .style("text-anchor", "middle") .text("W") .attr("transform", "translate(" + this.cellSize * -0.75 + ")") .attr("x", 0) .attr("y", 4 * this.cellSize); textGroup.append("text") .style("text-anchor", "middle") .text("F") .attr("transform", "translate(" + this.cellSize * -0.75 + ")") .attr("x", 0) .attr("y", 6 * this.cellSize); textGroup.append("text") .attr("transform", "translate(" + (this.width - (3 * this.cellSize)) + "," + this.cellSize * 3.5 + ")rotate(90)") .style("text-anchor", "middle") .text(function (d) { return d; }); textGroup.selectAll(".month") .data((d) => { return d3.time.months(new Date(d, 0, 1), new Date(d + 1, 0, 1)); }) .enter() .append("text") .attr("transform", (d) => { return "translate(" + d3.time.weekOfYear(d) * this.cellSize + ", -5)"; }) .text((d) => { return d3.time.format("%b")(d); }); } this.rect = svg.selectAll(".day") .data(this.getDaysOfYear) .enter().append("rect") .attr("width", this.cellSize) .attr("height", this.cellSize) .attr("class", "day") .attr("style", "fill: #eeeeee; stroke-width: 2px; stroke: #ffffff") .attr("x", this.getXPosition) .attr("y", this.getYPosition) .datum(format); this.rect.append("title") .text(function (d) { return d; }); if (this.drawMonthPath) { svg.selectAll(".month") .data(function (d) { return d3.time.months(new Date(d, 0, 1), new Date(d + 1, 0, 1)); }) .enter().append("path") .attr("class", "month") .attr("d", this.monthPath) .attr("stroke", "#cccccc"); } if (this.drawLegend) { var legendGroup = d3.select(this.element).insert("svg", ":first-child") .attr("width", itemWidth) .attr("height", itemWidth / 17.5) .attr("viewBox", "0 0 " + this.width + " " + this.height / 7) .attr("preserveAspectRatio", "xMinYMin") .append("g"); legendGroup.append("rect") .attr("width", this.cellSize) .attr("height", this.cellSize) .attr("x", 0).attr("y", 0) .attr("fill", "#000000"); legendGroup.append("rect") .attr("width", this.cellSize) .attr("height", this.cellSize) .attr("x", 0).attr("y", this.cellSize * 1.5) .attr("fill", "#00ff00"); legendGroup .append("text").text(0) .attr("x", this.cellSize * 2).attr("y", this.cellSize); legendGroup .append("text").text(d3.format(".4r")(maxDomain)) .attr("x", this.cellSize * 2).attr("y", this.cellSize * 2.5); } } private apply(viewModel: CalendarViewModel, maxDomain: number) { var pad = (n: any) => { if (n.toString().length === 1) { return "0" + n; } return n.toString(); }; var quantizeColor = d3.scale.quantize() .domain([0, maxDomain]) .range(d3.range(256).map(function (d) { return "#00" + pad(d.toString(16)) + "00"; })); var data = d3.nest() .key(function (d: DateValue) { return d.date.getFullYear() + "-" + pad(d.date.getMonth()) + "-" + pad(d.date.getDate()); }) .rollup(function (d: DateValue[]) { return d.map((dateValue) => { return dateValue.value; }).reduce((prev, curr) => prev + curr); }) .map(viewModel.values); this.rect.filter(function (d) { return d in data; }) .attr("style", function (d) { return "fill:" + quantizeColor(data[d]); }) .select("title") .text(function (d) { return d + ": " + d3.format(".6f")(data[d]); }); } private convert(dataView: DataView): CalendarViewModel { if (dataView.categorical.categories == null) { window.console.log("no categoricals"); return; } var returnSet = dataView.categorical.categories[0].values.map( (v, i) => { return <DateValue> { date: v, value: dataView.categorical.values.map((val) => { return val.values[i]; }) .reduce((prev, curr) => { return prev + curr; }) }; }); return <CalendarViewModel> { values: returnSet }; } public getYears(viewModel: CalendarViewModel) { var allYears = viewModel.values.map((value) => { if (value.date == null || isNaN(Date.parse(value.date.toString()))) { return 1900; }; return value.date.getFullYear(); }); var uniqueYears = {}, a = []; for (var i = 0, l = allYears.length; i < l; ++i) { if (uniqueYears.hasOwnProperty(allYears[i].toString())) { continue; } a.push(allYears[i]); uniqueYears[allYears[i].toString()] = 1; } return a.sort(); } private getDaysOfYear = (year: number) => { return d3.time.days(new Date(year, 0, 1), new Date(year + 1, 0, 1)); }; public getXPosition = (date: Date) => { return d3.time.weekOfYear(date) * this.cellSize; }; public getYPosition = (date: Date) => { return date.getDay() * this.cellSize; }; private monthPath = (t0) => { var t1 = new Date(t0.getFullYear(), t0.getMonth() + 1, 0), d0 = t0.getDay(), w0 = d3.time.weekOfYear(t0), d1 = t1.getDay(), w1 = d3.time.weekOfYear(t1); return "M" + (w0 + 1) * this.cellSize + "," + d0 * this.cellSize + "H" + w0 * this.cellSize + "V" + 7 * this.cellSize + "H" + w1 * this.cellSize + "V" + (d1 + 1) * this.cellSize + "H" + (w1 + 1) * this.cellSize + "V" + 0 + "H" + (w0 + 1) * this.cellSize + "Z"; }; } } module powerbi.visuals.plugins { export var _CalendarVisual: IVisualPlugin = { name: '_CalendarVisual', class: '_CalendarVisual', capabilities: CalendarVisual.capabilities, create: () => new CalendarVisual() }; }