import { action, computed, IReactionDisposer, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import * as d3 from 'd3'; import { Doc, DocListCast, StrListCast } from '../../../../../fields/Doc'; import { Id } from '../../../../../fields/FieldSymbols'; import { List } from '../../../../../fields/List'; import { listSpec } from '../../../../../fields/Schema'; import { Cast, DocCast, StrCast } from '../../../../../fields/Types'; import { Docs } from '../../../../documents/Documents'; import { DocumentManager } from '../../../../util/DocumentManager'; import { LinkManager } from '../../../../util/LinkManager'; import { PinProps, PresBox } from '../../trails'; import { DataVizBox } from '../DataVizBox'; import { createLineGenerator, drawLine, minMaxRange, scaleCreatorNumerical, xAxisCreator, xGrid, yAxisCreator, yGrid } from '../utils/D3Utils'; import './Chart.scss'; import { EditableText, Size } from 'browndash-components'; import { undoable } from '../../../../util/UndoManager'; export interface DataPoint { x: number; y: number; } export interface SelectedDataPoint extends DataPoint { elem?: d3.Selection; } export interface LineChartProps { rootDoc: Doc; layoutDoc: Doc; axes: string[]; pairs: { [key: string]: any }[]; width: number; height: number; dataDoc: Doc; fieldKey: string; margin: { top: number; right: number; bottom: number; left: number; }; } @observer export class LineChart extends React.Component { private _disposers: { [key: string]: IReactionDisposer } = {}; private _lineChartRef: React.RefObject = React.createRef(); private _lineChartSvg: d3.Selection | undefined; @observable _currSelected: SelectedDataPoint | undefined = undefined; // TODO: nda - some sort of mapping that keeps track of the annotated points so we can easily remove when annotations list updates @computed get _lineChartData() { var guids = StrListCast(this.props.layoutDoc.dataViz_rowGuids); if (this.props.axes.length <= 1) return []; return this.props.pairs ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.dataViz_selectedRows && StrListCast(this.incomingLinks[0].dataViz_selectedRows).includes(guids[this.props.pairs.indexOf(pair)]))) .map(pair => ({ x: Number(pair[this.props.axes[0]]), y: Number(pair[this.props.axes[1]]) })) .sort((a, b) => (a.x < b.x ? -1 : 1)); } @computed get graphTitle() { return this.props.axes[1] + ' vs. ' + this.props.axes[0] + ' Line Chart'; } @computed get incomingLinks() { return LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) // out of all links .filter(link => { return link.link_anchor_1 == this.props.rootDoc.draggedFrom; }) // get links where this chart doc is the target of the link .map(link => DocCast(link.link_anchor_1)); // then return the source of the link } @computed get incomingSelected() { // return selected x and y axes // otherwise, use the selection of whatever is linked to us return this.incomingLinks // all links that are pointing to this node .map(anchor => DocumentManager.Instance.getFirstDocumentView(anchor)?.ComponentView as DataVizBox) // get their data viz boxes .filter(dvb => dvb) .map(dvb => dvb.pairs?.filter(pair => pair['select' + dvb.rootDoc[Id]])) // get all the datapoints they have selected field set by incoming anchor .lastElement(); } @computed get rangeVals(): { xMin?: number; xMax?: number; yMin?: number; yMax?: number } { return minMaxRange([this._lineChartData]); } componentWillUnmount() { Array.from(Object.keys(this._disposers)).forEach(key => this._disposers[key]()); } componentDidMount = () => { this._disposers.chartData = reaction( () => ({ dataSet: this._lineChartData, w: this.width, h: this.height }), ({ dataSet, w, h }) => { if (dataSet) { this.drawChart([dataSet], this.rangeVals, w, h); } }, { fireImmediately: true } ); this._disposers.annos = reaction( () => DocListCast(this.props.dataDoc[this.props.fieldKey + '_annotations']), annotations => { // modify how d3 renders so that anything in this annotations list would be potentially highlighted in some way // could be blue colored to make it look like anchor // this.drawAnnotations() // loop through annotations and draw them // annotations.forEach(a => this.drawAnnotations(Number(a.x), Number(a.y))); // this.drawAnnotations(annotations.x, annotations.y); }, { fireImmediately: true } ); this._disposers.highlights = reaction( () => ({ selected: this._currSelected, incomingSelected: this.incomingSelected, }), ({ selected, incomingSelected }) => { // redraw annotations when the chart data has changed, or the local or inherited selection has changed if (selected){ const elements = document.querySelectorAll('.datapoint'); for (let i = 0; i < elements.length; i++) { const element = elements[i]; const x = element.getAttribute('data-x'); const y = element.getAttribute('data-y'); if (Number(x) === Number(selected.x) && Number(y) === Number(selected.y)) element.classList.add('selected'); } } }, { fireImmediately: true } ); if (!this.props.layoutDoc.dataViz_lineChart) this.props.layoutDoc.dataViz_lineChart = ''; }; @action restoreView = (data: Doc) => { const coords = Cast(data.config_dataVizSelection, listSpec('number'), null); if (coords?.length > 1 && (this._currSelected?.x !== coords[0] || this._currSelected?.y !== coords[1])) { this.setCurrSelected(coords[0], coords[1]); return true; } if (this._currSelected) { this.setCurrSelected(); return true; } return false; }; // create a document anchor that stores whatever is needed to reconstruct the viewing state (selection,zoom,etc) getAnchor = (pinProps?: PinProps) => { const anchor = Docs.Create.ConfigDocument({ // title: 'line doc selection' + this._currSelected?.x, }); PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this.props.rootDoc); anchor.config_dataVizSelection = this._currSelected ? new List([this._currSelected.x, this._currSelected.y]) : undefined; return anchor; }; @computed get height() { return this.props.height - this.props.margin.top - this.props.margin.bottom; } @computed get width() { return this.props.width - this.props.margin.left - this.props.margin.right; } @computed get defaultGraphTitle() { var ax0 = this.props.axes[0]; var ax1 = this.props.axes.length > 1 ? this.props.axes[1] : undefined; if (this.props.axes.length < 2 || !/\d/.test(this.props.pairs[0][ax0]) || !ax1) { return ax0 + ' Line Chart'; } else return ax1 + ' by ' + ax0 + ' Line Chart'; } setupTooltip() { return d3 .select(this._lineChartRef.current) .append('div') .attr('class', 'tooltip') .style('opacity', 0) .style('background', '#fff') .style('border', '1px solid #ccc') .style('padding', '5px') .style('position', 'absolute') .style('font-size', '12px'); } // TODO: nda - use this everyewhere we update currSelected? @action setCurrSelected(x?: number, y?: number) { // TODO: nda - get rid of svg element in the list? if (this._currSelected && this._currSelected.x == x && this._currSelected.y == y) this._currSelected = undefined; else this._currSelected = x !== undefined && y !== undefined ? { x, y } : undefined; this.props.pairs.forEach(pair => pair[this.props.axes[0]] === x && pair[this.props.axes[1]] === y && (pair.selected = true)); } drawDataPoints(data: DataPoint[], idx: number, xScale: d3.ScaleLinear, yScale: d3.ScaleLinear) { if (this._lineChartSvg) { const circleClass = '.circle-' + idx; this._lineChartSvg .selectAll(circleClass) .data(data) .join('circle') // enter append .attr('class', `${circleClass} datapoint`) .attr('r', '3') // radius .attr('cx', d => xScale(d.x)) .attr('cy', d => yScale(d.y)) .attr('data-x', d => d.x) .attr('data-y', d => d.y); } } // TODO: nda - can use d3.create() to create html element instead of appending drawChart = (dataSet: any[][], rangeVals: { xMin?: number; xMax?: number; yMin?: number; yMax?: number }, width: number, height: number) => { // clearing tooltip and the current chart d3.select(this._lineChartRef.current).select('svg').remove(); d3.select(this._lineChartRef.current).select('.tooltip').remove(); const { xMin, xMax, yMin, yMax } = rangeVals; if (xMin === undefined || xMax === undefined || yMin === undefined || yMax === undefined) { return; } // creating the x and y scales const xScale = scaleCreatorNumerical(xMin, xMax, 0, width); const yScale = scaleCreatorNumerical(0, yMax, height, 0); // adding svg const margin = this.props.margin; const svg = (this._lineChartSvg = d3 .select(this._lineChartRef.current) .append('svg') .attr('class', 'graph') .attr('width', `${width + margin.left + margin.right}`) .attr('height', `${height + margin.top + margin.bottom}`) .append('g') .attr('transform', `translate(${margin.left}, ${margin.top})`)); // create x and y grids xGrid(svg.append('g'), height, xScale); yGrid(svg.append('g'), width, yScale); xAxisCreator(svg.append('g'), height, xScale); yAxisCreator(svg.append('g'), width, yScale); // get valid data points const data = dataSet[0]; const lineGen = createLineGenerator(xScale, yScale); var validData = data.filter(d => { var valid = true; Object.keys(data[0]).map(key => { if (!d[key] || Number.isNaN(d[key])) valid = false; }); return valid; }); // draw the plot line drawLine(svg.append('path'), validData, lineGen); // draw the datapoint circle this.drawDataPoints(validData, 0, xScale, yScale); const higlightFocusPt = svg.append('g').style('display', 'none'); higlightFocusPt.append('circle').attr('r', 5).attr('class', 'circle'); const tooltip = this.setupTooltip(); // add all the tooltipContent to the tooltip const mousemove = action((e: any) => { const bisect = d3.bisector((d: DataPoint) => d.x).left; const xPos = d3.pointer(e)[0]; const x0 = Math.min(data.length - 1, bisect(data, xScale.invert(xPos - 5))); // shift x by -5 so that you can reach points on the left-side axis const d0 = data[x0]; if (!d0) return; this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); }); const onPointClick = action((e: any) => { const bisect = d3.bisector((d: DataPoint) => d.x).left; const xPos = d3.pointer(e)[0]; const x0 = bisect(data, xScale.invert(xPos - 5)); // shift x by -5 so that you can reach points on the left-side axis const d0 = data[x0]; // find .circle-d1 with data-x = d0.x and data-y = d0.y const selected = svg.selectAll('.datapoint').filter((d: any) => d['data-x'] === d0.x && d['data-y'] === d0.y); this.setCurrSelected(d0.x, d0.y); this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); }); svg.append('rect') .attr('class', 'overlay') .attr('width', width) .attr('height', this.height + margin.top + margin.bottom) .attr('fill', 'none') .attr('translate', `translate(${margin.left}, ${-(margin.top + margin.bottom)})`) .style('opacity', 0) .on('mouseover', () => higlightFocusPt.style('display', null)) .on('mouseout', () => tooltip.transition().duration(300).style('opacity', 0)) .on('mousemove', mousemove) .on('click', onPointClick); // axis titles svg.append('text') .attr('transform', 'translate(' + width / 2 + ' ,' + (height + 40) + ')') .style('text-anchor', 'middle') .text(this.props.axes[0]); svg.append('text') .attr('transform', 'rotate(-90)' + ' ' + 'translate( 0, ' + -10 + ')') .attr('x', -(height / 2)) .attr('y', -20) .attr('height', 20) .attr('width', 20) .style('text-anchor', 'middle') .text(this.props.axes[1]); }; private updateTooltip( higlightFocusPt: d3.Selection, xScale: d3.ScaleLinear, d0: DataPoint, yScale: d3.ScaleLinear, tooltip: d3.Selection ) { higlightFocusPt.attr('transform', `translate(${xScale(d0.x)},${yScale(d0.y)})`).attr('class', this._currSelected?.x === d0.x && this._currSelected?.y === d0.y ? 'hoverHighlight-selected' : 'hoverHighlight'); tooltip.transition().duration(300).style('opacity', 0.9); // TODO: nda - updating the inner html could be deadly cause injection attacks! tooltip .html(() => `(${d0.x},${d0.y})`) // text content for tooltip .style('pointer-events', 'none') .style('transform', `translate(${xScale(d0.x) - this.width}px,${yScale(d0.y)}px)`); } render() { this.componentDidMount(); var titleAccessor: any = ''; if (this.props.axes.length == 2) titleAccessor = 'dataViz_lineChart_title' + this.props.axes[0] + '-' + this.props.axes[1]; else if (this.props.axes.length > 0) titleAccessor = 'dataViz_lineChart_title' + this.props.axes[0]; if (!this.props.layoutDoc[titleAccessor]) this.props.layoutDoc[titleAccessor] = this.defaultGraphTitle; const selectedPt = this._currSelected ? `{ ${this.props.axes[0]}: ${this._currSelected.x} ${this.props.axes[1]}: ${this._currSelected.y} }` : 'none'; if (this._lineChartData.length>0 || (!this.incomingLinks || this.incomingLinks.length==0)){ return this.props.axes.length>=2 && /\d/.test(this.props.pairs[0][this.props.axes[0]]) && /\d/.test(this.props.pairs[0][this.props.axes[1]]) ? (
(this.props.layoutDoc[titleAccessor] = val as string)), 'Change Graph Title' )} color={'black'} size={Size.LARGE} fillWidth />
{selectedPt != 'none' ?
{`Selected: ${selectedPt}`}
: null}
) : ( {'first use table view to select two numerical axes to plot'} ); } else return ( // when it is a brushed table and the incoming table doesn't have any rows selected
Selected rows of data from the incoming DataVizBox to display.
); } }