import { d3Event, select as d3Select, SVGZoomWidget, Utility } from "@hpcc-js/common"; import { HTMLTooltip } from "@hpcc-js/html"; import { scaleLinear as d3ScaleLinear } from "d3-scale"; import { React, render, LabelledRect } from "@hpcc-js/react"; export type IGanttData = [ string, number, number, any? ]; export interface IRangeOptions { rangePadding: number; fontFamily: string; fontSize: number; strokeWidth?: number; fill: string; stroke: string; textFill: string; cornerRadius: number; } export class ReactGantt extends SVGZoomWidget { protected _selection = new Utility.Selection(this); protected _buckets; protected _interpolateX; protected _interpolateY; protected _bucketsBySeries; protected _dataBySeries; protected _origIdxMap; private _seriesBackgrounds; protected _maxFontSize; public _tooltip; public _minStart: number; public _maxEnd: number; protected _title_idx = 0; protected _startDate_idx = 1; protected _endDate_idx = 2; protected _icon_idx = -1; protected _color_idx = -1; protected _series_idx = -1; protected _bucket_idx = -1; protected _yoffset_idx = -1; protected _maxX: number; protected _maxY: number; private _rangeOptions: IRangeOptions = { rangePadding: 2, fontFamily: "Verdana", fontSize: 12, fill: "white", stroke: "black", textFill: "black", cornerRadius: 3, strokeWidth: 0 }; constructor(drawStartPosition: "origin" | "center" = "origin") { super(); this._drawStartPos = drawStartPosition; this.showToolbar_default(false); this._tooltip = new HTMLTooltip(); this._tooltip .tooltipHTML(d => { return `
${d[0]}

${d[1]} -> ${d[2]}
`; }); this._tooltip .followCursor(true) ; } private _rangeRenderer: React.FunctionComponent = LabelledRect; rangeRenderer(): React.FunctionComponent; rangeRenderer(_: React.FunctionComponent): this; rangeRenderer(_?: React.FunctionComponent): this | React.FunctionComponent { if (!arguments.length) return this._rangeRenderer; this._rangeRenderer = _; return this._rangeRenderer; } enter(domNode, element){ super.enter(domNode, element); const context = this; element .on("click", function (this: SVGElement, d) { context._selection.clear(); }); this._tooltip.target(domNode); } update(domNode, element){ super.update(domNode, element); this.zoomExtent([0.05, this.maxZoom()]); this._title_idx = this.titleColumn() !== null ? this.columns().indexOf(this.titleColumn()) : this._title_idx; this._startDate_idx = this.startDateColumn() !== null ? this.columns().indexOf(this.startDateColumn()) : this._startDate_idx; this._endDate_idx = this.endDateColumn() !== null ? this.columns().indexOf(this.endDateColumn()) : this._endDate_idx; this._icon_idx = this.iconColumn() !== null ? this.columns().indexOf(this.iconColumn()) : this._icon_idx; this._color_idx = this.colorColumn() !== null ? this.columns().indexOf(this.colorColumn()) : this._color_idx; this._series_idx = this.seriesColumn() !== null ? this.columns().indexOf(this.seriesColumn()) : this._series_idx; this._bucket_idx = this.bucketColumn() !== null ? this.columns().indexOf(this.bucketColumn()) : -1; const context = this; const w = this.width(); const x0 = 0; const x1 = w; this._interpolateX = d3ScaleLinear() .domain([this._minStart, this._maxEnd]) .range([x0, x1]) ; this.data().sort((a, b)=>a[1]-b[1]); if(this._series_idx > -1) { this._origIdxMap = {}; this._dataBySeries = {}; this._bucketsBySeries = {}; this.data().forEach((dataRow, origIdx)=>{ const seriesKey = dataRow[this._series_idx]; if(!this._dataBySeries[seriesKey]) { this._origIdxMap[seriesKey] = {}; this._dataBySeries[seriesKey] = []; } this._dataBySeries[seriesKey].push({ dataRow, origIdx }); }); const gutter = this.gutter(); let bucketOffset = 0; const seriesKeys = Object.keys(this._dataBySeries); seriesKeys.forEach(seriesKey=>{ this._dataBySeries[seriesKey].sort((a, b)=>a.dataRow[1]-b.dataRow[1]); this._bucketsBySeries[seriesKey] = this.calcBuckets(this._dataBySeries[seriesKey].map(n=>n.dataRow), 1, 2); this._bucketsBySeries[seriesKey].bucketHeight = this.bucketHeight(); this._bucketsBySeries[seriesKey].bucketOffset = bucketOffset; bucketOffset += (this._bucketsBySeries[seriesKey].bucketHeight + this.strokeWidth() + this.gutter()) * (this._bucketsBySeries[seriesKey].maxBucket+1); this._dataBySeries[seriesKey].forEach((n, i) => { this._origIdxMap[seriesKey][n.origIdx] = i; }); }); this._seriesBackgrounds = this._renderElement.selectAll(".series-background") .data(seriesKeys.map(key=>{ return this._bucketsBySeries[key]; })) ; this._seriesBackgrounds .join( enter => enter.append("rect") .attr("class", "series-background"), update => update, exit => exit .each(function (d) { delete d.element; }) .remove() ) .attr("opacity", d => d.props && d.props.hidden ? 0 : 1) .each(function (this: SVGGElement, d, i) { d3Select(this) .attr("x", 0) .attr("y", d.bucketOffset - (gutter/2)) .attr("width", w) .attr("height", ((d.bucketHeight + gutter) * (d.maxBucket + 1)) + gutter) .attr("fill", i%2 ? context.oddSeriesBackground() : context.evenSeriesBackground()) ; }); } else { if(this._bucket_idx !== -1){ this._buckets = this.calcBuckets(this.data(), this._startDate_idx, this._endDate_idx, this._bucket_idx); } else { this._buckets = this.calcBuckets(this.data(), this._startDate_idx, this._endDate_idx); } } const interpedStart = this._interpolateX(this._minStart); this.zoomTo( [interpedStart, 0], 1 ); const bucketHeight = this.bucketHeight(); this.setRangeOptions(); this._maxFontScale = (bucketHeight - (this.rangePadding() * 2)); this.measureDataText(); const itemSelection = this._renderElement.selectAll(".item") .data(this.data()) ; const borderOffset1 = this.strokeWidth(); const borderOffset2 = borderOffset1 * 2; itemSelection .join( enter => enter.append("g") .attr("class", "item") .on("click.selectionBag", function (d, i) { const _id = d.id === undefined ? i : d.id; if(context._selection.isSelected({_id, element: d.element})){ context._selection.clear(); } else { context._selection.click( { _id, element: () => d.element }, d3Event ); } context.selectionChanged(); d3Event().stopPropagation(); }) .on("click", function (this: SVGElement, d) { const selected = d.element.classed("selected"); if(d[context.columns().length]){ d.__lparam = d[context.columns().length]; } context.click(d, "", selected); }) .on("dblclick", function (this: SVGElement, d) { const selected = d.element.classed("selected"); if(d[context.columns().length]){ d.__lparam = d[context.columns().length]; } context.click(d, "", selected); }) .on("mousein", function (d) { context.highlightItem(d3Select(this), d); const selected = d.element.classed("selected"); context.mousein(d, "", selected); }) .on("mouseover", function (d) { const d3evt = d3Event(); context._tooltip._triggerElement = d.element; context._tooltip._cursorLoc = [ d3evt.clientX, d3evt.clientY ]; context._tooltip .data(d) .visible(true) .fitContent(true) .render() ; context.highlightItem(d3Select(this), d); const selected = d.element.classed("selected"); context.mouseover(d, "", selected); }) .on("mouseout", function (d) { context._tooltip .visible(false) .render() ; context.highlightItem(null, null); const selected = d.element.classed("selected"); context.mouseout(d, "", selected); }) .each(function (d, i) { d.that = this; d.element = d3Select(this); d.x = context._interpolateX(d[1]); const endX = context._interpolateX(d[2]); if(context._series_idx > -1) { const seriesKey = d[context._series_idx]; const bucket = context._bucketsBySeries[seriesKey].bucketMap[context._origIdxMap[seriesKey][i]]; d.y = context._bucketsBySeries[seriesKey].interpolateY(bucket) + context._bucketsBySeries[seriesKey].bucketOffset; } else { const _i = context._bucket_idx === -1 ? i : d[context._bucket_idx]; d.y = context._buckets.interpolateY(context._buckets.bucketMap[_i]); } d.props={ ...d[3], text: d[0] }; d.props.width = endX - d.x; d.props.height = bucketHeight; d.x += borderOffset1; d.y += borderOffset1; d.props.width -= borderOffset2; d.props.height -= borderOffset2; d.element.attr("transform", `translate(${d.x+(d.props.width/2)} ${d.y+(d.props.height/2)})`); }), update => update, exit => exit .each(function (d) { delete d.element; }) .remove() ) .attr("opacity", d => d.props && d.props.hidden ? 0 : 1) .each(function (this: SVGGElement, d, i) { d.that = this; if(context._series_idx > -1){ const seriesKey = d[context._series_idx]; d.x = context.renderRangeElement(d, i, false, context._rangeOptions, seriesKey); } else { d.x = context.renderRangeElement(d, i, false, context._rangeOptions); } }) .on("dblclick.zoom", d => { const x1 = this._interpolateX(d[1]); const x2 = this._interpolateX(d[2]); const xRange = x2 - x1; const xScale = w / xRange; this.zoomTo( [ -x1 * xScale, 0 ], xScale ); }) ; element.on("dblclick.zoom", null); } renderRangeElement(d, i, transformEach = false, options: any = {}, seriesKey?: string) { const borderOffset1 = options.strokeWidth; const borderOffset2 = borderOffset1 * 2; const padding = options.rangePadding; let endX; const x = isNaN(this._transform.x) ? 0 : this._transform.x; const k = isNaN(this._transform.k) ? 1 : this._transform.k; let b; const bucketHeight = this.bucketHeight(); d.that.setAttribute("data-series", seriesKey); if(this._color_idx > -1){ d.that.setAttribute("data-color", d[this._color_idx]); } if(seriesKey !== undefined) { b = this._bucketsBySeries[seriesKey].bucketMap[this._origIdxMap[seriesKey][i]]; d.that.setAttribute("data-b", b); d.that.setAttribute("data-bucketOffset", this._bucketsBySeries[seriesKey].bucketOffset); d.y = this._bucketsBySeries[seriesKey].interpolateY(b) + this._bucketsBySeries[seriesKey].bucketOffset; d.that.setAttribute("data-dy", d.y); } else { b = this._buckets.bucketMap[i]; d.y = this._buckets.interpolateY(b); } if(this._color_idx > -1) { options.fill = d[this._color_idx]; } if (!transformEach) { d.x = this._interpolateX(d[1]); endX = this._interpolateX(d[2]); d.props={ ...d[3], text: d[0] }; d.props.width = (endX - d.x) / k; } else { d.x = this._interpolateX(d[1]) * k; endX = this._interpolateX(d[2]) * k; d.props={ ...d[3], text: d[0] }; d.props.width = (endX - d.x)/k; d.x += x; d.props.width *= k; } d.props.height = bucketHeight; if(seriesKey === undefined && this._buckets.bucketScale < 1) { d.props.height = this._buckets.bucketScale * bucketHeight; } if(d.element === undefined && d.that){ d.element = d3Select(d.that); } d.element.attr("transform", `translate(${d.x+(d.props.width/2)} ${d.y+(d.props.height/2)})`); d.x += borderOffset1; d.y += borderOffset1; d.props.width -= borderOffset2; d.props.height -= borderOffset2; d.props.width = d.props.width < 1 ? 1 : d.props.width; d.props.height = d.props.height < 1 ? 1 : d.props.height; let text = this.truncateText(d.props.text, d.props.width - padding, this._maxFontScale); if(text !== d.props.text){ text = this.truncateText(d.props.text, d.props.width - padding); } else { d.props.fontSize = this._maxFontScale * options.fontSize; } if(seriesKey === undefined && this._buckets.bucketScale < 1) { d.props.fontSize = Math.min(this._maxFontScale, this._buckets.bucketScale) * options.fontSize; } if(!this._maxY || this._maxY < d.y + d.props.height) { this._maxY = d.y + d.props.height; } if(!this._maxX || this._maxX < d.x + d.props.width) { this._maxX = d.x + d.props.width; } render( this._rangeRenderer, { ...options, ...d.props, text, }, d.that ); } setRangeOptions(){ this._rangeOptions = { rangePadding: this.rangePadding(), fontFamily: this.fontFamily(), fontSize: this.fontSize(), strokeWidth: this.strokeWidth(), fill: this.fill(), stroke: this.stroke(), textFill: this.rangeFontColor(), cornerRadius: this.cornerRadius(), }; } public _transform = {k:1, x:0, y:0}; zoomed(transform) { this._transform = transform; switch(this.renderMode()){ case "scale-all": this._zoomScale = transform.k; this._zoomTranslate = [transform.x, 0]; this._zoomG.attr("transform", `translate(${transform.x} ${0})scale(${transform.k} 1)`); break; default: const options = this._rangeOptions; this.data().forEach((d, i)=>{ if(this._color_idx > -1){ options.fill = d[this._color_idx]; } if(this._series_idx > -1){ const seriesKey = d[this._series_idx]; this.renderRangeElement(d, i, true, options, seriesKey); } else { this.renderRangeElement(d, i, true, options); } }); } this.zoomedHook(transform); } zoomedHook(transform) { } private calcBuckets(data, startKey: string | number, endKey: string | number, bucketKey?: string | number) { const bucketMap = {}; const bucketKeyMap = {}; const tol = this.overlapTolerence(); const buckets = [{end:-Infinity}]; let maxBucket = 0; if(bucketKey !== undefined) { data.forEach((d, i)=>{ bucketMap[i] = d[bucketKey]; bucketKeyMap[d[bucketKey]] = true; }); maxBucket = Object.keys(bucketKeyMap).length; } else { data.forEach((d, i)=>{ for (let i2 = 0; i2 < buckets.length; ++i2) { if (i === 0 || buckets[i2][endKey] + tol <= d[startKey]) { bucketMap[i] = i2; if(maxBucket < i2)maxBucket = i2; buckets[i2][endKey] = d[endKey]; break; } } if(bucketMap[i] === undefined){ bucketMap[i] = buckets.length; const b = {}; b[endKey] = d[endKey]; buckets.push(b as any); } if(maxBucket < bucketMap[i])maxBucket = bucketMap[i]; }); } const height = (maxBucket+1) * (this.bucketHeight() + this.gutter()); return { bucketMap, maxBucket, bucketScale: this.height() / height, interpolateY: d3ScaleLinear() .domain([0, maxBucket+1]) .range([0, Math.min(this.height(), height)]) }; } data(): IGanttData[]; data(_: IGanttData[]): this; data(_?: IGanttData[]): this | IGanttData[] { const retVal = super.data.apply(this, arguments); if(arguments.length > 0) { this._minStart = Math.min(...this.data().map(n=>n[1])) ?? 0; this._maxEnd = Math.max(...this.data().map(n=>n[2])) ?? 1; this.measureDataText(true); } return retVal; } protected _textWidths; protected _maxFontScale; protected _characterWidths; protected _prevFontFamily; protected _prevFontSize; measureDataText(forceMeasure = false) { const textWidths = {}; const characterWidths = {}; const fontFamily = this.fontFamily(); const fontSize = this.fontSize(); const bucketHeight = this.bucketHeight(); if(bucketHeight){ this._maxFontScale = (bucketHeight - (this.rangePadding() * 2)) / fontSize; } if(forceMeasure || this._prevFontFamily !== fontFamily || this._prevFontSize !== fontSize) { characterWidths["."] = Utility.textSize(".", fontFamily, fontSize).width; this.data().forEach(d=>{ if(!textWidths[d[0]]){ textWidths[d[0]] = Utility.textSize(d[0], fontFamily, fontSize).width; } d[0].split("").forEach(char=>{ if(!characterWidths[char]){ characterWidths[char] = Utility.textSize(char, fontFamily, fontSize).width; } }); }); this._textWidths = textWidths; this._characterWidths = characterWidths; } this._prevFontFamily = fontFamily; this._prevFontSize = fontSize; } truncateText(text, width, scale = 1) { const textFits = this._textWidths[text] * scale < width; if(textFits){ return text; } let ret = ""; let sum = 0; const _width = width - (this._characterWidths["."] * 3); for(const char of text){ sum += this._characterWidths[char]; if(sum < _width){ ret += char; } else { break; } } return _width < 0 ? "" : ret + "..."; } resize(_size?: { width: number, height: number }) { let retVal; if(this.fitWidthToContent() || this.fitHeightToContent()) { retVal = super.resize.call(this, { width: _size.width, height: this._maxY }); } else { retVal = super.resize.apply(this, arguments); } return retVal; } selectionChanged() { } highlightItem(_element, d) { } click(row, _col, sel) { } dblclick(row, _col, sel) { } mousein(row, _col, sel) { } mouseover(row, _col, sel) { } mouseout(row, _col, sel) { } } ReactGantt.prototype._class += " timeline_ReactGantt"; export interface ReactGantt { titleColumn(): string; titleColumn(_: string): this; startDateColumn(): string; startDateColumn(_: string): this; endDateColumn(): string; endDateColumn(_: string): this; iconColumn(): string; iconColumn(_: string): this; colorColumn(): string; colorColumn(_: string): this; seriesColumn(): string; seriesColumn(_: string): this; bucketColumn(): string; bucketColumn(_: string): this; overlapTolerence(): number; overlapTolerence(_: number): this; smallestRangeWidth(): number; smallestRangeWidth(_: number): this; bucketHeight(): number; bucketHeight(_: number): this; gutter(): number; gutter(_: number): this; showToolbar_default(_: boolean): this; fontSize(): number; fontSize(_: number): this; fontFamily(): string; fontFamily(_: string): this; strokeWidth(): number; strokeWidth(_: number): this; stroke(): string; stroke(_: string): this; cornerRadius(): number; cornerRadius(_: number): this; fill(): string; fill(_: string): this; rangeFontColor(): string; rangeFontColor(_: string): this; rangePadding(): number; rangePadding(_: number): this; renderMode(): "default" | "scale-all"; renderMode(_: "default" | "scale-all"): this; maxZoom(): number; maxZoom(_: number): this; fitWidthToContent(): boolean; fitWidthToContent(_: boolean): this; fitHeightToContent(): boolean; fitHeightToContent(_: boolean): this; evenSeriesBackground(): string; evenSeriesBackground(_: string): this; oddSeriesBackground(): string; oddSeriesBackground(_: string): this; } ReactGantt.prototype.publish("fitWidthToContent", false, "boolean", "If true, resize will simply reapply the bounding box width"); ReactGantt.prototype.publish("fitHeightToContent", false, "boolean", "If true, resize will simply reapply the bounding box height"); ReactGantt.prototype.publish("titleColumn", null, "string", "Column name to for the title"); ReactGantt.prototype.publish("startDateColumn", null, "string", "Column name to for the start date"); ReactGantt.prototype.publish("endDateColumn", null, "string", "Column name to for the end date"); ReactGantt.prototype.publish("iconColumn", null, "string", "Column name to for the icon"); ReactGantt.prototype.publish("colorColumn", null, "string", "Column name to for the color"); ReactGantt.prototype.publish("seriesColumn", null, "string", "Column name to for the series identifier"); ReactGantt.prototype.publish("bucketColumn", null, "string", "Column name to for the bucket identifier"); ReactGantt.prototype.publish("renderMode", "default", "set", "Render modes vary in features and performance", ["default", "scale-all"]); ReactGantt.prototype.publish("rangePadding", 3, "number", "Padding within each range rectangle (pixels)"); ReactGantt.prototype.publish("fill", "#1f77b4", "string", "Background color of range rectangle"); ReactGantt.prototype.publish("stroke", null, "string", "Color of range rectangle border"); ReactGantt.prototype.publish("strokeWidth", null, "number", "Width of range rectangle border (pixels)"); ReactGantt.prototype.publish("cornerRadius", 3, "number", "Space between range buckets (pixels)"); ReactGantt.prototype.publish("fontFamily", null, "string", "Font family within range rectangle", null, {optional:true}); ReactGantt.prototype.publish("fontSize", 10, "number", "Size of font within range rectangle (pixels)"); ReactGantt.prototype.publish("rangeFontColor", "#ecf0f1", "html-color", "rangeFontColor"); ReactGantt.prototype.publish("overlapTolerence", 2, "number", "overlapTolerence"); ReactGantt.prototype.publish("smallestRangeWidth", 10, "number", "Width of the shortest range (pixels)"); ReactGantt.prototype.publish("bucketHeight", 100, "number", "Max height of range element (pixels)"); ReactGantt.prototype.publish("gutter", 2, "number", "Space between range buckets (pixels)"); ReactGantt.prototype.publish("maxZoom", 16, "number", "Maximum zoom"); ReactGantt.prototype.publish("evenSeriesBackground", "#FFFFFF", "html-color", "Background color of even series rows"); ReactGantt.prototype.publish("oddSeriesBackground", "#DDDDDD", "html-color", "Background color of odd series rows");