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 {
public _triggerElement;
public _contentNode;
protected _prevContentNode;
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;
}
tooltipContent(_): this {
if (!arguments.length) return this._contentNode;
this._contentNode = _;
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);
if(this._contentNode !== this._prevContentNode){
const node = this._tooltipElement.node();
[...node.querySelectorAll("*")]
.map(n=>n.__data__)
.filter(n=>n)
.forEach(w=>{
if(typeof w.target === "function"){
w.target(null);
}
if(typeof w.exit === "function"){
w.exit();
}
});
node.innerHTML = "";
node.appendChild(this._contentNode);
this._prevContentNode = this._contentNode;
}
if (this._contentNode) {
this.onShowContent(this._contentNode);
} else {
this._tooltipElement
.html(() => {
return this._tooltipHTMLCallback(this.data());
});
}
if(this.fitContent()){
this._tooltipElement
.style("width", "auto")
.style("height", "auto")
.style("padding", "0px")
.style("box-sizing", "content-box")
;
const rect = this._tooltipElement.node().getBoundingClientRect();
this.tooltipWidth_default(rect.width);
this.tooltipHeight_default(rect.height);
}
this._closing = false;
this._tooltipElement
.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", this.enablePointerEvents() ? "all" : "none")
.style("box-sizing", "content-box")
;
this._arrowElement
.style("opacity", 1)
.style("pointer-events", "none")
;
this.updateTooltipPosition();
}
onShowContent(node) {
}
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();
}
public _cursorLoc;
protected calcReferenceBBox() {
const node = this.getReferenceNode();
let { top, left, width, height } = 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;
if(this.followCursor() && this._cursorLoc) {
left = this._cursorLoc[0];
top = this._cursorLoc[1];
width = 1;
height = 1;
}
const bbox = {
n: {
x: left + (width / 2) - halfW - p,
y: top - wholeH - arrowH - p2
},
e: {
x: left + width + arrowH,
y: top + (height / 2) - halfH - p
},
s: {
x: left + (width / 2) - halfW - p,
y: top + height + arrowH
},
w: {
x: left - wholeW - arrowH - p2,
y: top + (height / 2) - halfH - p
},
nw: {
x: left - wholeW - p2,
y: top - wholeH - p2
},
ne: {
x: left + width,
y: top - wholeH - p2
},
se: {
x: left + width,
y: top + height
},
sw: {
x: left - wholeW - p2,
y: top + height
}
};
return bbox;
}
private _closing = false;
mouseout() {
this._closing = true;
this._tooltipElement.on("mouseover", () => {
this._closing = false;
});
this._tooltipElement.on("mouseout", () => {
this.mouseout();
});
setTimeout(()=>{
if(this._closing){
this.visible(false);
}
}, this.closeDelay());
}
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;
tooltipWidth_default(_: number);
tooltipHeight(): number;
tooltipHeight(_: number): this;
tooltipHeight_default(_: number);
followCursor(): boolean;
followCursor(_: boolean): this;
enablePointerEvents(): boolean;
enablePointerEvents(_: boolean): this;
closeDelay(): number;
closeDelay(_: number): this;
fitContent(): boolean;
fitContent(_: boolean): this;
}
HTMLTooltip.prototype.publish("fitContent", false, "boolean", "If true, tooltip will grow to fit its html content");
HTMLTooltip.prototype.publish("followCursor", false, "boolean", "If true, tooltip will display relative to cursor location");
HTMLTooltip.prototype.publish("closeDelay", 400, "number", "Number of milliseconds to wait before closing tooltip (cancelled on tooltip mouseover event)");
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)");
HTMLTooltip.prototype.publish("enablePointerEvents", false, "boolean", "If true, the 'pointer-events: all' style will be used");