UNPKG

highcharts-tree

Version:
735 lines (699 loc) 23.5 kB
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; } } ); };