highcharts-tree
Version:
Create tree type in Highcharts
735 lines (699 loc) • 23.5 kB
text/typescript
import { TreeNodeData, HighchartsTreeConfig } from "../types";
import TreeNode from "./TreeNode";
import Tree from "./Tree";
interface ElementObjectExtended extends Highcharts.ElementObject {
added: boolean;
}
const SHAPE_OFFSET = 3;
const NODE_WIDTH_OFFSET = SHAPE_OFFSET * 2;
// @types/highcharts not support some method
export default (Highcharts: any) => {
const highchartMajorVersion = Highcharts.version.split(".")[0];
const defaultConfig: HighchartsTreeConfig = {
horizontal: true,
disableToggle: false,
node: {
width: 200,
height: 0, // null || 0 = auto-calculated
marginX: 20,
marginY: 20,
textColor: "#454d59",
backgroundColor: "#f2f2f2",
backgroundColorToggle: "#cccccc",
title: {
marginTop: 4,
marginDown: 4
},
padding: {
x: 1.5,
y: 1.5
},
content: {
align: "left"
},
hover: {
backgroundColor: "#d6d6d6",
backgroundColorToggle: "#cccccc"
},
border: {
width: 3,
color: "#919191"
},
dataFormatter(data: string | number) {
return data.toLocaleString();
}
},
row: {
height: 20,
line: 2
},
tooltip: {
enabled: false,
backgroundColor: "rgba(0,0,0,0.6)",
borderRadius: "3px",
textColor: "#fff",
width: 0, // set 0 to use node width
tooltipFormatter(item: TreeNodeData) {
return (
`${item.content.title}` +
(item.content.data ? `<br>${item.content.data.join("<br>")}` : "")
);
}
},
connector: {
color: "#bcbcbc",
width: 4
},
legend: {
enabled: true,
nodeWidth: 20,
marginX: 10,
marginY: 25
}
};
const { seriesType, seriesTypes, each, merge } = Highcharts;
seriesType(
"tree",
"pie",
{
config: defaultConfig
},
{
init() {
Highcharts.addEvent(Highcharts.Chart, "redraw", (e: Event) => {
const chart = e.target;
if (chart !== this.chart) return;
// ignore setSize
if (this._skipTranslate) {
this._skipTranslate = false;
return;
}
this._tree.build();
this.translate();
});
Highcharts.addEvent(Highcharts.Chart, "destroy", (e: Event) => {
const chart = e.target;
if (chart !== this.chart) return;
Highcharts.removeEvent(chart, "redraw");
Highcharts.removeEvent(chart, "destroy");
});
seriesTypes.pie.prototype.init.apply(this, arguments);
const { data } = this.options;
if (
data === null ||
typeof data === "undefined" ||
typeof data.tree === "undefined" ||
data.tree === null
) {
return;
}
this._config = merge(
{},
this.options.config,
this.chart.userOptions.chart.config
);
this._tree = Tree.getTree({
tree: this._tree,
data: this.options.data.tree,
horizontal: this._config.horizontal
});
},
translate() {
const data = this.options.data;
const ren = this.chart.renderer;
const config: HighchartsTreeConfig = this._config;
config.node.height = config.node.height || this._treeComputedNodeHeight || 0; // 0 is false, and _treeComputedNodeHeight is undefined initial
const colors = this.chart.options.colors;
let maxX = 0;
let maxY = 0;
let elements: ElementObjectExtended[] = this._elements;
const staticProps = ren.styledMode
? {
titleCss: {},
textCss: {},
tooltipStr: {
style: "",
class: `class="highcharts-tree-node-tooltip-inner"`
},
tooltipAttr: {
class: "highcharts-tree-node-tooltip"
},
boxAttr: { zIndex: 0 },
connectorAttr: {
class: "highcharts-tree-connector"
}
}
: {
titleCss: {
pointerEvents: "none",
fontSize: "14px",
color: config.node.textColor,
fontWeight: "bold",
textAlign: "center"
},
textCss: {
fontSize: "13px",
color: config.node.textColor,
textOverflow: "ellipsis",
pointerEvents: "none"
},
tooltipStr: {
style: `border-radius: ${config.tooltip.borderRadius};
background-color: ${config.tooltip.backgroundColor};
color: ${config.tooltip.textColor};
font-size: 12px;
white-space: initial;
word-break: break-word;`,
class: ""
},
tooltipAttr: {
opacity: 0
},
boxAttr: {
zIndex: 0,
stroke: config.node.border.color, // basic
"stroke-width": config.node.border.width // hyphenated
},
connectorAttr: {
"stroke-width": config.connector.width,
stroke: config.connector.color
}
};
if (!elements) this._elements = elements = [];
const drawNode = (node: TreeNode<TreeNodeData>) => {
const box = {
x:
node.x * (config.node.width + config.node.marginX) +
config.node.border.width / 2,
y:
node.y * (config.node.height + config.node.marginY) +
this._titleOffsetY,
w: config.node.width,
h: config.node.height
};
// title, needs to be added to getBBox()
const titleElement = ren
.label(
node.item.content.title,
box.x + config.node.padding.x,
box.y + config.node.padding.y + config.node.title.marginTop,
"rect",
undefined,
undefined,
undefined,
undefined,
"tree-node-title"
)
.attr({ zIndex: 1 })
.add();
/**
* in this line, highcharts will get the computed style
* so add it to RenderObject first
* @link
* https://github.com/highcharts/highcharts/blob/master/ts/parts/SvgRenderer.ts#L3716
*/
titleElement.css({
...staticProps.titleCss,
width: box.w - NODE_WIDTH_OFFSET - config.node.padding.x * 2,
textOverflow: "ellipsis"
});
// - center it
titleElement.attr({ x: box.x + box.w / 2 - titleElement.width / 2 });
elements.push(titleElement);
// rows
const offsetTextByLegend = config.legend.enabled
? config.legend.nodeWidth
: 0;
const rowsY =
titleElement.height +
config.node.title.marginTop +
config.node.title.marginDown;
if (node.item.content.data) {
for (let i = 0; i < node.item.content.data.length; i++) {
if (config.legend.enabled) {
// legend box
elements.push(
ren
.rect(
box.x + config.node.padding.x,
box.y + rowsY + config.row.height * i,
config.legend.nodeWidth,
config.row.height
)
.css({ pointerEvents: "none" })
.attr(
ren.styledMode
? { class: `highcharts-tree-legend-${i}`, zIndex: 1 }
: { fill: colors[i], zIndex: 1 }
)
);
}
const text = config.node.dataFormatter(
node.item.content.data[i],
i
);
const textAlign = config.node.content.align;
const textElementWidth =
box.w -
offsetTextByLegend -
NODE_WIDTH_OFFSET -
config.node.padding.x * 2;
const computedWidth =
textAlign === "center" ? undefined : textElementWidth;
const textElement = ren
.label(
text,
box.x + offsetTextByLegend + config.node.padding.x,
box.y + rowsY + config.row.height * i,
"rect",
undefined,
undefined,
undefined,
undefined,
"tree-node-data"
)
.attr({
zIndex: 1,
width: computedWidth
})
.add();
textElement.css({
...staticProps.textCss,
width: computedWidth
});
// - allign right
// textElement.attr({x: box.x + box.w - textElement.width - config.row.marginX});
if (textAlign === "center") {
textElement.attr({
x: box.x + box.w / 2 - textElement.width / 2
});
}
elements.push(textElement);
}
}
// calculate node height(if not set) based on rendered content
let needRedraw = false;
if (
typeof config.node.height === "undefined" ||
config.node.height === null ||
config.node.height < 1
) {
config.node.height = box.h =
rowsY +
config.row.line * config.row.height +
config.node.padding.y;
this._treeComputedNodeHeight = config.node.height;
needRedraw = true;
}
if (node === this._tree.root) {
// region resize
const { width, height } = this.chart.userOptions.chart;
if (!width || !height) {
let changed = false;
const curWidth =
this._tree.root.width *
(config.node.width + config.node.marginX) +
config.node.width +
config.node.border.width;
const curHeight =
this._tree.root.height *
(config.node.height + config.node.marginY) +
config.node.height +
config.legend.marginY +
config.row.height +
this._titleOffsetY;
if (!width && curWidth !== this.chart.chartWidth) {
changed = true;
this.chart.renderTo.style.width = `${curWidth}px`;
}
if (!height && curHeight !== this.chart.chartHeight) {
changed = true;
this.chart.renderTo.style.height = `${curHeight}px`;
}
if (changed) {
needRedraw = false;
this._elements = elements;
this._skipTranslate = true; // setSize will trigger redraw event
this.chart.setSize(curWidth, curHeight, false);
return false;
}
}
// endregion
}
if (needRedraw) {
this.translate();
return false;
}
// main box
// console.log(box.x);
let tooltipElement: ElementObjectExtended = null;
if (config.tooltip.enabled) {
let tooltipX = box.x + box.w;
const tooltipW = config.tooltip.width || box.w;
if (tooltipX + tooltipW > this.chart.chartWidth) {
tooltipX = box.x - tooltipW;
}
const styleStr = `
min-height: ${box.h}px;
margin-left: -${SHAPE_OFFSET}px;
margin-top: -${SHAPE_OFFSET}px;
width: ${tooltipW}px;${staticProps.tooltipStr.style}`;
tooltipElement = ren
.label(
`<div
${staticProps.tooltipStr.class}
style="${styleStr}">${config.tooltip.tooltipFormatter(node.item)}</div>`,
tooltipX,
box.y,
"rect",
undefined,
undefined,
true
)
.css({
pointerEvents: "none"
})
.attr({
...staticProps.tooltipAttr,
zIndex: 5
})
.add();
elements.push(tooltipElement);
}
const boxElement = ren
.rect(box.x, box.y, box.w, box.h, 3)
.css({
cursor: node.children.length < 1 ? "default" : "pointer"
})
.attr({
...staticProps.boxAttr,
id: node.item.id,
...(ren.styledMode
? {
class: `highcharts-tree-box${node.toggle ? "" : " fold"}`
}
: {
fill: node.toggle
? config.node.backgroundColor
: config.node.backgroundColorToggle
})
})
.on("mouseover", () => {
if (ren.styledMode) {
if (tooltipElement) {
tooltipElement.attr({
class: "highcharts-tree-node-tooltip hover"
});
}
return;
}
if (highchartMajorVersion > 5) {
boxElement.animate(
{
fill: node.toggle
? config.node.hover.backgroundColor
: config.node.hover.backgroundColorToggle
},
{ duration: 300 }
);
} else {
boxElement.attr({
fill: node.toggle
? config.node.hover.backgroundColor
: config.node.hover.backgroundColorToggle
});
}
if (tooltipElement) {
tooltipElement.animate({ opacity: 1 }, { duration: 300 });
}
})
.on("mouseout", () => {
if (ren.styledMode) {
if (tooltipElement) {
tooltipElement.attr({
class: "highcharts-tree-node-tooltip"
});
}
return;
}
if (highchartMajorVersion > 5) {
boxElement.animate(
{
fill: node.toggle
? config.node.backgroundColor
: config.node.backgroundColorToggle
},
{ duration: 300 }
);
} else {
boxElement.attr({
fill: node.toggle
? config.node.backgroundColor
: config.node.backgroundColorToggle
});
}
if (tooltipElement) {
tooltipElement.animate({ opacity: 0 }, { duration: 300 });
}
});
elements.push(boxElement);
if (node.children.length > 0 && !config.disableToggle) {
boxElement.on("click", () => {
node.toggle = !node.toggle;
this._tree.build();
this.translate();
});
}
// region draw connector line
// draw line to parent
if (node.parent != null) {
if (config.horizontal) {
elements.push(
ren
.path([
"M",
box.x + box.w / 2,
box.y,
"L",
box.x + box.w / 2,
box.y - config.node.marginY / 2 - config.connector.width / 2
])
.attr(staticProps.connectorAttr)
);
} else {
elements.push(
ren
.path([
"M",
box.x,
box.y + box.h / 2,
"L",
box.x -
config.node.marginX / 2 -
config.connector.width / 2,
box.y + box.h / 2
])
.attr(staticProps.connectorAttr)
);
}
}
if (node.toggle) {
// draw line to children
if (node.children.length > 0) {
if (config.horizontal) {
const nodeBottomMiddle = {
x: box.x + box.w / 2,
y: box.y + box.h
};
elements.push(
ren
.path([
"M",
nodeBottomMiddle.x,
nodeBottomMiddle.y,
"L",
nodeBottomMiddle.x,
nodeBottomMiddle.y + config.node.marginY / 2
])
.attr(staticProps.connectorAttr)
);
// draw line over children
if (node.children.length > 1) {
const offsetX = config.node.width + config.node.marginX;
const linePositionY =
nodeBottomMiddle.y + config.node.marginY / 2;
elements.push(
ren
.path([
"M",
node.getRightMostChild().x * offsetX +
config.node.width / 2 -
config.connector.width / 2 +
config.node.border.width / 2,
linePositionY,
"L",
node.getLeftMostChild().x * offsetX +
config.node.width / 2 +
config.connector.width / 2 +
config.node.border.width / 2,
linePositionY
])
.attr(staticProps.connectorAttr)
);
}
} else {
const nodeRightMiddle = {
x: box.x + box.w,
y: box.y + box.h / 2
};
elements.push(
ren
.path([
"M",
nodeRightMiddle.x,
nodeRightMiddle.y,
"L",
nodeRightMiddle.x + config.node.marginX / 2,
nodeRightMiddle.y
])
.attr(staticProps.connectorAttr)
);
// draw line over children
if (node.children.length > 1) {
const offsetY = config.node.height + config.node.marginY;
const linePositionX =
nodeRightMiddle.x + config.node.marginX / 2;
// bottom to top
elements.push(
ren
.path([
"M",
linePositionX,
node.getRightMostChild().y * offsetY +
config.node.height / 2 -
config.connector.width / 2 +
config.node.border.width / 2 +
this._titleOffsetY,
"L",
linePositionX,
node.getLeftMostChild().y * offsetY +
config.node.height / 2 -
config.connector.width / 2 +
config.node.border.width / 2 +
this._titleOffsetY
])
.attr(staticProps.connectorAttr)
);
}
}
}
each(node.children, drawNode);
}
// endregion
maxX = Math.max(maxX, box.x + box.w);
maxY = Math.max(maxY, box.y + box.h);
return true;
};
// clear the previous
each(elements, (element: ElementObjectExtended) => {
element.destroy();
});
this._elements = elements = [];
if (
data === null ||
typeof data === "undefined" ||
typeof data.tree === "undefined" ||
data.tree === null
) {
// error
elements.push(
ren.label("Invalid data.", 0, 0).css({
fontSize: "14px",
color: "#EE0000",
fontWeight: "bold"
})
);
} else {
this._titleOffsetY = 0;
if (this.chart.title) {
const titleBox = this.chart.title.getBBox();
this._titleOffsetY =
titleBox.y + titleBox.height + this.chart.options.title.margin;
}
// draw tree
if (!drawNode(this._tree.root)) {
return;
}
// draw legend
if (config.legend.enabled) {
const legends = data.legends || [];
let offsetX = 0;
for (
let i = 0;
i < Math.min(legends.length, config.row.line);
i++
) {
elements.push(
ren
.rect(
offsetX + config.legend.marginX,
maxY + config.legend.marginY,
config.legend.nodeWidth,
config.row.height
)
.attr(
ren.styledMode
? { class: `highcharts-tree-legend-${i}` }
: { fill: colors[i] }
)
);
/*spacing between legend box and legend text*/
offsetX += config.legend.nodeWidth + 5;
const legendTextElement = ren
.label(
legends[i].text,
offsetX + config.legend.marginX,
maxY + config.legend.marginY
)
.css(
ren.styledMode
? {}
: {
fontSize: "14px",
color: config.node.textColor,
fontWeight: "bold"
}
)
.attr(
ren.styledMode
? {
class: "highcharts-tree-legend-text",
zIndex: 1
}
: { zIndex: 1 }
)
.add();
elements.push(legendTextElement);
/*spacing between legends */
offsetX += legendTextElement.width + 30;
}
}
}
each(elements, (element: ElementObjectExtended) => {
if (element.added) return;
element.add();
});
this._elements = elements;
},
animate() {
return;
},
drawPoints() {
return;
},
drawDataLabels() {
return;
}
}
);
};