import { HTMLWidget, Palette, Utility } from "@hpcc-js/common"; import { extent as d3Extent, range as d3Range } from "d3-array"; import { map as d3Map } from "d3-collection"; import { format as d3Format } from "d3-format"; import { select as d3Select } from "d3-selection"; import { timeDays as d3TimeDays, timeMonths as d3TimeMonths, timeWeek as d3TimeWeek, timeYear as d3TimeYear } from "d3-time"; import { timeParse as d3TimeParse } from "d3-time-format"; import "../src/CalendarHeatMap.css"; export class CalendarHeatMap extends HTMLWidget { _prevDateColumn; _prevAggrType; _prevAggrColumn; _prevAggrDeltaColumn; _view; _parentNode; constructor() { super(); Utility.SimpleSelectionMixin.call(this); } calendarData() { if (this.fields().length === 0 || this.data().length === 0) { return []; } const dateParser = d3TimeParse(this.datePattern()); const valueFormatter = this.aggrDeltaColumn() ? d3Format(".1%") : d3Format("s"); if (this._prevDateColumn !== this.dateColumn() || this._prevAggrType !== this.aggrType() || this._prevAggrColumn !== this.aggrColumn() || this._prevAggrDeltaColumn !== this.aggrDeltaColumn()) { this._prevDateColumn = this.dateColumn(); this._prevAggrType = this.aggrType(); this._prevAggrColumn = this.aggrColumn(); this._prevAggrDeltaColumn = this.aggrDeltaColumn(); this._view = this._db.aggregateView([this.dateColumn()], this.aggrType(), this.aggrColumn(), this.aggrDeltaColumn()); } return this._view.entries().map(function (row) { row.dateKey = dateParser(row.key); row.formattedValues = valueFormatter(row.value.aggregate); row.origRows = row.value; return row; }); } calcDelta(row) { return (row.Close - row.Open) / row.Open; } enter(domNode, element) { super.enter(domNode, element); d3Select(domNode.parentNode) .style("overflow-y", "scroll") .style("overflow-x", "hidden") .style("height", "100%") .style("width", "100%") ; this._selection.widgetElement(element); } update(domNode, element) { super.update(domNode, element); this._palette = this._palette.switch(this.paletteID()); const width = this.width(); const cellSize = (width / 12) / 5; const height = cellSize * 8; const data = this.calendarData(); const mappedData = d3Map(data, function (d: any) { return d.dateKey; }); const dateExtent = d3Extent(data, function (d: any) { return d.dateKey.getFullYear(); }); const context = this; const svg = element.selectAll("svg").data(d3Range(+dateExtent[0], +dateExtent[1] + 1)); const svgUpdate = svg.enter().append("svg") .each(function (d) { const svgElement = d3Select(this); const g = svgElement.append("g"); g.append("text") .style("text-anchor", "middle") ; g.append("g") .attr("class", "days") ; const _d3TimeMonths = d3TimeMonths(new Date(d, 0, 1), new Date(d + 1, 0, 1)); const _months = g.append("g").attr("class", "months"); _d3TimeMonths.forEach(function (_m) { _months.append("path") .attr("class", "month") .attr("d", calcMonthPath(_m)) .style("stroke", context.monthStrokeColor()) .style("stroke-width", context.monthStrokeWidth()) ; }); }) .merge(svg) .attr("width", width) .attr("height", height) ; svgUpdate.select("g") .attr("transform", "translate(" + ((width - cellSize * 53) / 2) + "," + (height - cellSize * 7 - 1) + ")") ; svgUpdate.select("text") .attr("transform", "translate(-6," + cellSize * 3.5 + ")rotate(-90)") .text(d => d) ; svg.exit().remove(); let dataExtent: [any, any] = d3Extent(data, function (d: any) { return d.value.aggregate; }); if (this.aggrDeltaColumn()) { const max = Math.max(Math.abs(+dataExtent[0]), Math.abs(+dataExtent[1])); dataExtent = [-max, max]; } const dayRect = svgUpdate.select(".days").selectAll(".day").data(function (d) { return d3TimeDays(new Date(d, 0, 1), new Date(d + 1, 0, 1)); }); const dayRectUpdate = dayRect.enter().append("rect") .attr("class", "day") .call(this._selection.enter.bind(this._selection)) .on("click", function (d) { const data2 = mappedData.get(d); if (data2 && data2.value && data2.value && data2.value.length) { context.click(context.rowToObj(data2.value[0]), context.dateColumn(), context._selection.selected(this)); } }) .on("dblclick", function (d) { const data2 = mappedData.get(d); if (data2 && data2.value && data2.value && data2.value.length) { context.dblclick(context.rowToObj(data2.value[0]), context.dateColumn(), context._selection.selected(this)); } }).each(function (d) { const dayRectElement = d3Select(this); dayRectElement.append("title"); }) .merge(dayRect) .attr("x", function (d) { return d3TimeWeek.count(d3TimeYear(d), d) * cellSize; }) .attr("y", function (d) { return d.getDay() * cellSize; }) .attr("width", cellSize) .attr("height", cellSize) .style("stroke", this.dayStrokeColor()) .style("stroke-width", this.dayStrokeWidth()) .style("fill", null) ; dayRectUpdate.select("title") .text(d => d) ; dayRectUpdate.filter(function (d) { return mappedData.has(d); }) .style("fill", function (d) { const row = mappedData.get(d); if (!row || !row.value || !row.value.aggregate) { return null; } return context._palette(row.value.aggregate, dataExtent[0], dataExtent[1]); }) .select("title") .text(function (d) { const data2 = mappedData.get(d); return data2.key + ": " + data2.formattedValues; }) ; dayRect.exit().remove(); const monthPath = svg.select(".months").selectAll(".month").data(function (d) { return d3TimeMonths(new Date(d, 0, 1), new Date(d + 1, 0, 1)); }); monthPath.enter().append("path") .attr("class", "month") .merge(monthPath) .attr("d", calcMonthPath) .style("stroke", this.monthStrokeColor()) .style("stroke-width", this.monthStrokeWidth()) ; monthPath.exit().remove(); function calcMonthPath(t0) { const t1 = new Date(t0.getFullYear(), t0.getMonth() + 1, 0); const d0 = t0.getDay(); const w0 = d3TimeWeek.count(d3TimeYear(t0), t0); const d1 = t1.getDay(); const w1 = d3TimeWeek.count(d3TimeYear(t1), t1); return "M" + (w0 + 1) * cellSize + "," + d0 * cellSize + "H" + w0 * cellSize + "V" + 7 * cellSize + "H" + w1 * cellSize + "V" + (d1 + 1) * cellSize + "H" + (w1 + 1) * cellSize + "V" + 0 + "H" + (w0 + 1) * cellSize + "Z"; } } exit(domNode, element) { super.exit(domNode, element); } // Events --- click(row, column, selected) { console.log("Click: " + JSON.stringify(row) + ", " + column + ", " + selected); } dblclick(row, column, selected) { console.log("Double click: " + JSON.stringify(row) + ", " + column + ", " + selected); } _palette; paletteID: { (): string; (_: string): CalendarHeatMap }; paletteID_exists: () => boolean; dateColumn: { (): string; (_: string): CalendarHeatMap }; dateColumn_exists: () => boolean; datePattern: { (): string; (_: string): CalendarHeatMap }; datePattern_exists: () => boolean; aggrType: { (): string; (_: string): CalendarHeatMap }; aggrType_exists: () => boolean; aggrColumn: { (): string; (_: string): CalendarHeatMap }; aggrColumn_exists: () => boolean; aggrDeltaColumn: { (): string; (_: string): CalendarHeatMap }; aggrDeltaColumn_exists: () => boolean; // SimpleSelectionMixin _selection; } CalendarHeatMap.prototype._class += " other_CalendarHeatMap"; CalendarHeatMap.prototype.mixin(Utility.SimpleSelectionMixin); CalendarHeatMap.prototype._palette = Palette.rainbow("default"); export interface CalendarHeatMap { dayStrokeColor(): string; dayStrokeColor(_: string): this; monthStrokeColor(): string; monthStrokeColor(_: string): this; dayStrokeWidth(): number; dayStrokeWidth(_: number): this; monthStrokeWidth(): number; monthStrokeWidth(_: number): this; } CalendarHeatMap.prototype.publish("paletteID", "YlOrRd", "set", "Color palette for this widget", CalendarHeatMap.prototype._palette.switch(), { tags: ["Basic", "Shared"] }); CalendarHeatMap.prototype.publish("dayStrokeColor", "#ccc", "html-color", "Color of day border"); CalendarHeatMap.prototype.publish("monthStrokeColor", "#000", "html-color", "Color of month border"); CalendarHeatMap.prototype.publish("dayStrokeWidth", 1, "number", "Pixel width of day border"); CalendarHeatMap.prototype.publish("monthStrokeWidth", 2, "number", "Pixel width of month border"); CalendarHeatMap.prototype.publish("dateColumn", null, "set", "Date Column", function () { return this.columns(); }, { optional: true }); CalendarHeatMap.prototype.publish("datePattern", "%Y-%m-%d", "string", "Date Pattern"); CalendarHeatMap.prototype.publish("aggrType", null, "set", "Aggregation Type", [null, "mean", "median", "sum", "min", "max"], { optional: true }); CalendarHeatMap.prototype.publish("aggrColumn", null, "set", "Aggregation Field", function () { return this.columns(); }, { optional: true, disable: (w) => !w.aggrType() }); CalendarHeatMap.prototype.publish("aggrDeltaColumn", null, "set", "Aggregation Field", function () { return this.columns(); }, { optional: true, disable: (w) => !w.aggrType() });