import { I2DChart, ITooltip } from "@hpcc-js/api"; import { d3Event, InputField, SVGWidget, Utility } from "@hpcc-js/common"; import { degreesToRadians, normalizeRadians } from "@hpcc-js/util"; import { format as d3Format } from "d3-format"; import { interpolate as d3Interpolate } from "d3-interpolate"; import { select as d3Select } from "d3-selection"; import { arc as d3Arc, pie as d3Pie } from "d3-shape"; import "../src/Pie.css"; const sortAscending = (a, b) => a[1] - b[1] > 0 ? 1 : -1; const sortDescending = (a, b) => a[1] - b[1] > 0 ? -1 : 1; export class Pie extends SVGWidget { static __inputs: InputField[] = [{ id: "label", type: "string" }, { id: "value", type: "number" }]; protected _totalValue: number; d3Pie; d3Arc; d3LabelArc; private _labelPositions; private _smallValueLabelHeight; private _labelWidthLimit: number; private _quadIdxArr; private _minLabelTop = 0; private _maxLabelBottom = 0; private _seriesValueFormatter; private _seriesPercentageFormatter; constructor() { super(); I2DChart.call(this); ITooltip.call(this); Utility.SimpleSelectionMixin.call(this); this.d3Pie = d3Pie(); this.d3Arc = d3Arc(); this.d3LabelArc = d3Arc(); this .tooltipTick_default(false) .tooltipOffset_default(0) ; } intersection(pointA, pointB) { return this.intersectCircle(this.calcOuterRadius(), pointA, pointB); } calcInnerRadius() { return this.innerRadius_exists() ? this.calcOuterRadius() * (this.innerRadius() as number) / 100 : 0; } calcOuterRadius() { const maxTextWidth = this.textSize(this.data().map(d => this.getLabelText({ data: d }, false)), "Verdana", 12).width; const horizontalLimit = this._size.width - (this.showLabels() ? maxTextWidth * 2 : 0) - 20; const verticalLimit = this._size.height - 12 * 3 - (this.showLabels() ? this._smallValueLabelHeight : 0); const outerRadius = Math.min(horizontalLimit, verticalLimit) / 2 - 2; if ((horizontalLimit / 2) - 2 < this.minOuterRadius()) { this._labelWidthLimit = maxTextWidth - (this.minOuterRadius() - ((horizontalLimit / 2) - 2)); } else { this._labelWidthLimit = maxTextWidth; } if (outerRadius < this.minOuterRadius()) { return this.minOuterRadius(); } return outerRadius; } calcSmallValueLabelHeight() { const smallDef = 0.1; const totalVal = this.data().reduce((acc, n) => acc + n[1], 0); let smallCount = 0; this.data().forEach(row => { if (row[1] / totalVal < smallDef) { smallCount++; } }); return this.labelHeight() * smallCount; } calcTotalValue(): number { return this.data().reduce((acc, d) => { return acc + d[1]; }, 0); } calcPadAngleRadians(): number { const paddingValue = this.slicePadding(); return paddingValue > 0 ? Math.min(paddingValue, 0.05) : 0; } getLabelText(d, truncate?) { let len; let label = d.data[0]; if (typeof this._labelWidthLimit !== "undefined" && truncate) { const labelWidth = this.textSize(label, "Verdana", this.labelHeight()).width; if (this._labelWidthLimit < labelWidth) { len = label.length * (this._labelWidthLimit / labelWidth) - 3; label = len < label.length ? label.slice(0, len) + "..." : label; } } if (this.showSeriesValue()) { label += ` : ${this._seriesValueFormatter(d.data[1])}`; } if (this.showSeriesPercentage()) { let sum = this._totalValue; const dm = this.dataMeta(); if (typeof dm.sum !== "undefined") { sum = dm.sum; } const perc = (d.data[1] / sum) * 100; label += ` : ${this._seriesPercentageFormatter(perc)}%`; } return label; } selection(): any[]; // any[] === single row selection(_: any[]): this; selection(_?: any[]): any[] | this { if (!arguments.length) { try { return this._selection.selection2()[0]?.data; } catch (e) { return undefined; } } // Stringify to enable a deep equal const strRow = JSON.stringify(_); this._selection.selection2(d => strRow === JSON.stringify(d.data)); } selectByLabel(_: string) { const row = this.data().filter(row => row[0] === _)[0]; if (row) { this.selection(row); } } _slices; _labels; enter(domNode, element) { super.enter(domNode, element); this._selection .widgetElement(element) .skipBringToTop(true) ; this._slices = element.append("g"); this._labels = element.append("g"); const context = this; this .tooltipHTML(function (d) { switch (context.tooltipStyle()) { case "series-table": return context.tooltipFormat({ label: d.data[0], arr: context.columns().slice(1).map(function (column, i) { return { label: column, color: context._palette(d.data[0]), value: d.data[i + 1] }; }) }); default: return context.tooltipFormat({ label: d.data[0], value: d.data[1] }); } }) ; } update(_domNode, element) { this.selectionGlow(!this.tabNavigation()); super.update(_domNode, element); const context = this; this.updateD3Pie(); this._palette = this._palette.switch(this.paletteID()); this._seriesValueFormatter = d3Format(this.seriesValueFormat() as string); this._seriesPercentageFormatter = d3Format(this.seriesPercentageFormat() as string); if (this.useClonedPalette()) { this._palette = this._palette.cloneNotExists(this.paletteID() + "_" + this.id()); } this._smallValueLabelHeight = this.calcSmallValueLabelHeight(); this._totalValue = this.calcTotalValue(); const outerRadius = this.calcOuterRadius(); const innerRadius = Math.max(this.calcInnerRadius(), Math.min(outerRadius / 30, 6)); const labelRadius = outerRadius + 12; this.d3Arc .innerRadius(innerRadius) .padRadius(outerRadius) .outerRadius(outerRadius) .padAngle(this.calcPadAngleRadians()) ; this._quadIdxArr = [[], [], [], []]; const data = [...this.data()]; switch (this.sortDataByValue()) { case "ascending": data.sort(sortAscending); break; case "descending": data.sort(sortDescending); break; } const arc = this._slices.selectAll(".arc").data(this.d3Pie(data), d => d.data[0]); this._labelPositions = []; // Enter --- arc.enter().append("g") .attr("class", (d, i) => "arc series series-" + this.cssTag(d.data[0])) .attr("opacity", 0) .call(this._selection.enter.bind(this._selection)) .on("click", function (d) { context.click(context.rowToObj(d.data), context.columns()[1], context._selection.selected(this)); }) .on("dblclick", function (d) { context.dblclick(context.rowToObj(d.data), context.columns()[1], context._selection.selected(this)); }) .on("keydown", function (evt, d) { const event = d3Event(); if (context.tabNavigation() && (event.code === "Space" || event.key === "Enter")) { event.preventDefault(); context._selection.click(this); } }) .each(function (d, i) { d3Select(this).append("path") .on("mouseout.tooltip", context.tooltip.hide) .on("mousemove.tooltip", context.tooltip.show) .on("mouseover", arcTween(0, 0)) .on("mouseout", arcTween(-5, 150)) ; }) .merge(arc).transition() .attr("opacity", 1) .attr("tabindex", context.tabNavigation() ? "0" : null) .attr("role", context.tabNavigation() ? "button" : null) .attr("aria-label", context.tabNavigation() ? (d: any) => `${d.data[0]}: ${d.data[1]}` : null) .each(function (d, i) { const quad = context.getQuadrant(midAngle(d)); context._quadIdxArr[quad].push(i); d.outerRadius = outerRadius - 5; const element2 = d3Select(this); element2.select("path").transition() .attr("d", context.d3Arc) .style("fill", context.fillColor(d.data, context.columns()[1], d.data[1])) ; }) ; // Exit --- arc.exit().transition() .style("opacity", 0) .remove() ; // Labels --- this.d3LabelArc .innerRadius(labelRadius) .outerRadius(labelRadius) ; const text = this._labels.selectAll("text").data(this.showLabels() ? this.d3Pie(data) : [], d => d.data[0]); const mergedText = text.enter().append("text") .on("mouseout.tooltip", context.tooltip.hide) .on("mousemove.tooltip", context.tooltip.show) .attr("dy", ".5em") .on("click", function (d) { context._slices.selectAll("g").filter(function (d2) { if (d.data === d2.data) { context._selection.click(this); context.click(context.rowToObj(d.data), context.columns()[1], context._selection.selected(this)); } }); }) .on("dblclick", function (d) { context._slices.selectAll("g").filter(function (d2) { if (d.data === d2.data) { context.dblclick(context.rowToObj(d.data), context.columns()[1], context._selection.selected(this)); } }); }) .merge(text) .text(d => this.getLabelText(d, true)) .each(function (d, i) { const pos = context.d3LabelArc.centroid(d); const mid_angle = midAngle(d); pos[0] = labelRadius * (context.isLeftSide(mid_angle) ? 1 : -1); context._labelPositions.push({ top: pos[1], bottom: pos[1] + context.labelHeight() }); }); if (this.showLabels()) { this.adjustForOverlap(); mergedText.transition() .style("font-size", this.labelHeight() + "px") .attr("transform", (d, i) => { const pos = context.d3LabelArc.centroid(d); pos[0] = labelRadius * (context.isLeftSide(midAngle(d)) ? 1 : -1); pos[1] = context._labelPositions[i].top; return "translate(" + pos + ")"; }) .style("text-anchor", d => this.isLeftSide(midAngle(d)) ? "start" : "end"); } text.exit() .remove(); const polyline = this._labels.selectAll("polyline").data(this.showLabels() ? this.d3Pie(data) : [], d => this.getLabelText(d, true)); polyline.enter() .append("polyline") .merge(polyline).transition() .attr("points", function (d, i) { const pos = context.d3LabelArc.centroid(d); const pos1 = context.d3Arc.centroid(d); const pos2 = [...pos]; pos[0] = labelRadius * (context.isLeftSide(midAngle(d)) ? 1 : -1); pos[1] = context._labelPositions[i].top; return [pos1, pos2, pos]; }); polyline.exit() .remove(); if (this.showLabels()) { this.centerOnLabels(); } function midAngle(d) { return d.startAngle + (d.endAngle - d.startAngle) / 2; } function arcTween(outerRadiusDelta, delay) { return function () { d3Select(this).transition().delay(delay).attrTween("d", function (d: any) { const i = d3Interpolate(d.outerRadius, outerRadius + outerRadiusDelta); return function (t) { d.outerRadius = i(t); return context.d3Arc(d); }; }); }; } } isLeftSide(midAngle) { midAngle = normalizeRadians(midAngle); const isLeft = midAngle > Math.PI * 2 ? midAngle : midAngle < Math.PI && midAngle > 0; return isLeft; } getQuadrant(radians) { let quad = 0; const rad = normalizeRadians(radians); quad = rad <= Math.PI * 1.0 && rad >= Math.PI * 0.5 ? 3 : quad; quad = rad <= Math.PI * 0.5 && rad >= Math.PI * 0.0 ? 2 : quad; quad = rad <= Math.PI * 0.0 && rad >= Math.PI * -0.5 ? 1 : quad; return quad; } centerOnLabels() { const gY = this.pos().y; const gY2 = gY * 2; const radius = this.calcOuterRadius(); const top = Math.min(this._minLabelTop, -radius); const bottom = Math.max(this._maxLabelBottom, radius); const h = bottom - top; const heightDiff = gY2 - h; const absTop = Math.abs(this._minLabelTop); let yShift = 0; if (bottom > gY) { yShift = gY - bottom + (this.labelHeight() / 2); yShift -= heightDiff / 2; } else if (top < 0 && absTop > gY) { yShift = absTop - gY + (this.labelHeight() / 2); yShift += heightDiff / 2; } const pos = this.pos(); this.pos({ y: pos.y + yShift, x: pos.x }); } adjustForOverlap() { const labelHeight = this.labelHeight(); this._quadIdxArr.forEach((arr, quad) => { this._quadIdxArr[quad].sort((a, b) => { if (quad === 1 || quad === 2) { return this._labelPositions[a].top > this._labelPositions[b].top ? -1 : 1; } else if (quad === 0 || quad === 3) { return this._labelPositions[a].top > this._labelPositions[b].top ? 1 : -1; } }); let prevTop; this._quadIdxArr[quad].forEach((n, i) => { if (i > 0) { if (quad === 1 || quad === 2) { if (prevTop < this._labelPositions[n].bottom) { const overlap = this._labelPositions[n].bottom - prevTop; this._labelPositions[n].top -= overlap; this._labelPositions[n].bottom -= overlap; } } else if (quad === 0 || quad === 3) { if (prevTop + labelHeight > this._labelPositions[n].top) { const overlap = Math.abs(this._labelPositions[n].top) - Math.abs(prevTop + labelHeight); this._labelPositions[n].top -= overlap; this._labelPositions[n].bottom -= overlap; } } } prevTop = this._labelPositions[n].top; }); }); this._minLabelTop = 0; this._maxLabelBottom = 0; this._quadIdxArr.forEach((arr, quad) => { this._quadIdxArr[quad].forEach((n, i) => { if (this._minLabelTop > this._labelPositions[n].top) { this._minLabelTop = this._labelPositions[n].top; } if (this._maxLabelBottom < this._labelPositions[n].bottom) { this._maxLabelBottom = this._labelPositions[n].bottom; } }); }); } exit(domNode, element) { super.exit(domNode, element); } updateD3Pie() { const startAngle = normalizeRadians(degreesToRadians(this.startAngle())); switch (this.sortDataByValue()) { case "ascending": this.d3Pie.sort(sortAscending); break; case "descending": this.d3Pie.sort(sortDescending); break; default: this.d3Pie.sort(null); } this.d3Pie .padAngle(this.calcPadAngleRadians()) .startAngle(startAngle) .endAngle(2 * Math.PI + startAngle) .value(function (d) { return d[1]; }) ; } } Pie.prototype._class += " chart_Pie"; Pie.prototype.implements(I2DChart.prototype); Pie.prototype.implements(ITooltip.prototype); Pie.prototype.mixin(Utility.SimpleSelectionMixin); export interface Pie { showSeriesValue(): boolean; showSeriesValue(_: boolean): this; seriesValueFormat(): string; seriesValueFormat(_: string): this; showSeriesPercentage(): boolean; showSeriesPercentage(_: boolean): this; minOuterRadius(): number; minOuterRadius(_: number): this; startAngle(): number; startAngle(_: number): this; labelHeight(): number; labelHeight(_: number): this; slicePadding(): number; slicePadding(_: number): this; seriesPercentageFormat(): string; seriesPercentageFormat(_: string): this; showLabels(): boolean; showLabels(_: boolean): this; sortDataByValue(): "none" | "ascending" | "descending"; sortDataByValue(_: "none" | "ascending" | "descending"): this; paletteID(): string; paletteID(_: string): this; useClonedPalette(): boolean; useClonedPalette(_: boolean): this; outerText(): boolean; outerText(_: boolean): this; innerRadius(): number; innerRadius(_: number): this; innerRadius_exists(): boolean; // I2DChart _palette; fillColor(row: any[], column: string, value: number): string; textColor(row: any[], column: string, value: number): string; click(row, column, selected): void; dblclick(row, column, selected): void; // ITooltip tooltip; tooltipHTML(_): string; tooltipFormat(_): string; tooltipStyle(): "default" | "none" | "series-table"; tooltipTick(): boolean; tooltipTick(_: boolean): Pie; tooltipTick_default(): boolean; tooltipTick_default(_: boolean): Pie; tooltipOffset(): number; tooltipOffset(_: number): Pie; tooltipOffset_default(): number; tooltipOffset_default(_: number): Pie; // SimpleSelectionMixin _selection: Utility.SimpleSelection; // Tab Navigation tabNavigation(): boolean; tabNavigation(_: boolean): this; } Pie.prototype.publish("showLabels", true, "boolean", "If true, wedge labels will display"); Pie.prototype.publish("showSeriesValue", false, "boolean", "Append data series value next to label", null, { disable: w => !w.showLabels() }); Pie.prototype.publish("seriesValueFormat", ",.0f", "string", "Number format used for formatting series values", null, { disable: w => !w.showSeriesValue() }); Pie.prototype.publish("showSeriesPercentage", false, "boolean", "Append data series percentage next to label", null, { disable: w => !w.showLabels() }); Pie.prototype.publish("seriesPercentageFormat", ",.0f", "string", "Number format used for formatting series percentages", null, { disable: w => !w.showSeriesPercentage() }); Pie.prototype.publish("paletteID", "default", "set", "Color palette for this widget", Pie.prototype._palette.switch(), { tags: ["Basic", "Shared"] }); Pie.prototype.publish("useClonedPalette", false, "boolean", "Enable or disable using a cloned palette", null, { tags: ["Intermediate", "Shared"] }); Pie.prototype.publish("innerRadius", 0, "number", "Sets inner pie hole radius as a percentage of the radius of the pie chart", null, { tags: ["Basic"], range: { min: 0, step: 1, max: 100 } }); Pie.prototype.publish("minOuterRadius", 20, "number", "Minimum outer radius (pixels)"); Pie.prototype.publish("startAngle", 0, "number", "Starting angle of the first (and largest) wedge (degrees)"); Pie.prototype.publish("labelHeight", 12, "number", "Font size of labels (pixels)", null, { disable: w => !w.showLabels() }); Pie.prototype.publish("slicePadding", 0.01, "number", "Padding between pie slices (converted to pixels)", null, { tags: ["Basic"], range: { min: 0, step: 0.01, max: 0.2 } }); Pie.prototype.publish("sortDataByValue", "descending", "set", "Sort data by value", ["none", "ascending", "descending"]); Pie.prototype.publish("tabNavigation", false, "boolean", "Enable or disable tab navigation");