import { HTMLWidget } from "@hpcc-js/common"; import { scopedLogger, ScopedLogging } from "@hpcc-js/util"; import { select as d3Select } from "d3-selection"; type Direction = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; type Position = { x: number, y: number }; type DirectionalBBox = { [key in Direction]: Position; }; type Rectangle = { top: number, left: number, width: number, height: number }; export class HTMLTooltip extends HTMLWidget { protected _triggerElement; protected _tooltipElement; protected _arrowElement; protected _tooltipHTMLCallback = (data?) => "_tooltipHTMLCallback is undefined"; protected _logger: ScopedLogging = scopedLogger("html/HTMLTooltip"); constructor() { super(); this.visible(false); } tooltipHTML(_: (data?) => string): this { this._tooltipHTMLCallback = _; return this; } triggerElement(_): this { this._triggerElement = _; return this; } enter(domNode, element) { super.enter(domNode, element); const body = d3Select("body"); this._tooltipElement = body.append("div") .attr("class", "tooltip-div") .style("z-index", "2147483638") .style("position", "fixed") ; this._arrowElement = body.append("div") .attr("class", "arrow-div") .style("z-index", "2147483638") .style("position", "fixed") ; } update(domNode, element) { super.update(domNode, element); this._tooltipElement .html(() => { return this._tooltipHTMLCallback(this.data()); }) .style("background-color", this.tooltipColor()) .style("color", this.fontColor()) .style("width", this.tooltipWidth() + "px") .style("height", this.tooltipHeight() + "px") .style("opacity", 1) .style("padding", this.padding() + "px") .style("pointer-events", "none") ; this._arrowElement .style("opacity", 1) .style("pointer-events", "none") ; this.updateTooltipPosition(); } protected updateTooltipPosition(): Position { const bbox = this.calcReferenceBBox(); const direction = this.calcTooltipDirection(bbox); const box = bbox[direction]; this._tooltipElement .style("top", box.y + "px") .style("left", box.x + "px") ; this.setArrowPosition(box, direction); return box; } protected calcTooltipDirection(bbox: DirectionalBBox): Direction { const directions: Direction[] = Object.keys(bbox) as Direction[]; const defaultDirection = this.direction(); directions.sort((a, b) => a === defaultDirection ? -1 : 1); const windowRect = { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight }; for (let i = 0; i < directions.length; i++) { const tooltipRect = { top: bbox[directions[i]].y, left: bbox[directions[i]].x, width: this.tooltipWidth(), height: this.tooltipHeight() }; if (this.rectFits(tooltipRect, windowRect)) { return directions[i]; } } this._logger.warning(`Tooltip doesn't fit in the window for any of the directions. Defaulting to '${defaultDirection}'`); this._logger.debug(windowRect); this._logger.debug({ top: bbox[defaultDirection].y, left: bbox[defaultDirection].x, width: this.tooltipWidth(), height: this.tooltipHeight() }); return defaultDirection; } protected rectFits(innerRect: Rectangle, outerRect: Rectangle): boolean { return ( innerRect.top >= outerRect.top && innerRect.left >= outerRect.left && innerRect.width + innerRect.left <= outerRect.width + outerRect.left && innerRect.height + innerRect.top <= outerRect.height + outerRect.top ); } protected setArrowPosition(point: Position, direction: Direction) { let top; let left; let visibleBorderStyle = "border-top-color"; this._arrowElement .style("border", `${this.arrowHeight()}px solid ${this.tooltipColor()}`) .style("border-top-color", "transparent") .style("border-right-color", "transparent") .style("border-bottom-color", "transparent") .style("border-left-color", "transparent") ; switch (direction) { case "n": top = point.y + this.tooltipHeight() + (this.padding() * 2); left = point.x + (this.tooltipWidth() / 2) - (this.arrowWidth() / 2) + this.padding(); visibleBorderStyle = "border-top-color"; this._arrowElement .style("border-top-width", `${this.arrowHeight()}px`) .style("border-bottom-width", "0px") .style("border-left-width", `${this.arrowWidth() / 2}px`) .style("border-right-width", `${this.arrowWidth() / 2}px`) ; break; case "s": top = point.y - this.arrowHeight(); left = point.x + this.padding() + (this.tooltipWidth() / 2) - (this.arrowWidth() / 2); visibleBorderStyle = "border-bottom-color"; this._arrowElement .style("border-top-width", "0px") .style("border-bottom-width", `${this.arrowHeight()}px`) .style("border-left-width", `${this.arrowWidth() / 2}px`) .style("border-right-width", `${this.arrowWidth() / 2}px`) ; break; case "e": top = point.y + (this.tooltipHeight() / 2) + this.padding() - (this.arrowWidth() / 2); left = point.x - this.arrowHeight(); visibleBorderStyle = "border-right-color"; this._arrowElement .style("border-top-width", `${this.arrowWidth() / 2}px`) .style("border-bottom-width", `${this.arrowWidth() / 2}px`) .style("border-left-width", "0px") .style("border-right-width", `${this.arrowHeight()}px`) ; break; case "w": top = point.y + (this.tooltipHeight() / 2) - (this.arrowWidth() / 2) + this.padding(); left = point.x + this.tooltipWidth() + (this.padding() * 2); visibleBorderStyle = "border-left-color"; this._arrowElement .style("border-top-width", `${this.arrowWidth() / 2}px`) .style("border-bottom-width", `${this.arrowWidth() / 2}px`) .style("border-left-width", `${this.arrowHeight()}px`) .style("border-right-width", "0px") ; break; } if (typeof top !== "undefined" && typeof left !== "undefined") { this._arrowElement .style("top", top + "px") .style("left", left + "px") .style(visibleBorderStyle, this.tooltipColor()) .style("opacity", 1) ; } else { this._arrowElement .style("opacity", 0) ; } return point; } protected getReferenceNode() { if (!this._triggerElement) { return this.element().node().parentNode.parentNode; } return this._triggerElement.node(); } protected calcReferenceBBox() { const node = this.getReferenceNode(); const rect = node.getBoundingClientRect(); const wholeW = this.tooltipWidth(); const wholeH = this.tooltipHeight(); const halfW = wholeW / 2; const halfH = wholeH / 2; const arrowH = this.arrowHeight(); const p = this.padding(); const p2 = p * 2; const bbox = { n: { x: rect.left + (rect.width / 2) - halfW - p, y: rect.top - wholeH - arrowH - p2 }, e: { x: rect.left + rect.width + arrowH, y: rect.top + (rect.height / 2) - halfH - p }, s: { x: rect.left + (rect.width / 2) - halfW - p, y: rect.top + rect.height + arrowH }, w: { x: rect.left - wholeW - arrowH - p2, y: rect.top + (rect.height / 2) - halfH - p }, nw: { x: rect.left - wholeW - p2, y: rect.top - wholeH - p2 }, ne: { x: rect.left + rect.width, y: rect.top - wholeH - p2 }, se: { x: rect.left + rect.width, y: rect.top + rect.height }, sw: { x: rect.left - wholeW - p2, y: rect.top + rect.height } }; return bbox; } visible(): boolean; visible(_: boolean): this; visible(_?: boolean): boolean | this { if (!arguments.length) return super.visible(); if (this._arrowElement) { this._arrowElement.style("visibility", _ ? "visible" : "hidden"); this._tooltipElement.style("visibility", _ ? "visible" : "hidden"); } super.visible(_); return this; } exit(domNode, element) { if (this._arrowElement) { this._arrowElement.remove(); this._tooltipElement.remove(); } super.exit(domNode, element); } } HTMLTooltip.prototype._class += " html_HTMLTooltip"; export interface HTMLTooltip { padding(): number; padding(_: number): this; direction(): Direction; direction(_: Direction): this; arrowHeight(): number; arrowHeight(_: number): this; arrowWidth(): number; arrowWidth(_: number): this; fontColor(): string; fontColor(_: string): this; tooltipColor(): string; tooltipColor(_: string): this; tooltipWidth(): number; tooltipWidth(_: number): this; tooltipHeight(): number; tooltipHeight(_: number): this; } HTMLTooltip.prototype.publish("direction", "n", "set", "Direction in which to display the tooltip", ["n", "s", "e", "w", "ne", "nw", "se", "sw"]); HTMLTooltip.prototype.publish("padding", 8, "number", "Padding (pixels)"); HTMLTooltip.prototype.publish("arrowWidth", 16, "number", "Width (or height depending on direction) of the tooltip arrow (pixels)"); HTMLTooltip.prototype.publish("arrowHeight", 8, "number", "Height (or width depending on direction) of the tooltip arrow (pixels)"); HTMLTooltip.prototype.publish("fontColor", "#FFF", "html-color", "The default font color for text in the tooltip"); HTMLTooltip.prototype.publish("tooltipColor", "#000000EE", "html-color", "Background color of the tooltip"); HTMLTooltip.prototype.publish("tooltipWidth", 200, "number", "Width of the tooltip (not including arrow) (pixels)"); HTMLTooltip.prototype.publish("tooltipHeight", 200, "number", "Height of the tooltip (not including arrow) (pixels)");