import { Button, EditableText, Size } from 'browndash-components'; import * as d3 from 'd3'; import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Doc, NumListCast, StrListCast } from '../../../../../fields/Doc'; import { List } from '../../../../../fields/List'; import { listSpec } from '../../../../../fields/Schema'; import { Cast, DocCast, StrCast } from '../../../../../fields/Types'; import { Docs } from '../../../../documents/Documents'; import { undoable } from '../../../../util/UndoManager'; import {} from '../../../DocComponent'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; import { PinDocView, PinProps } from '../../../PinFuncs'; import { DocumentView } from '../../DocumentView'; import { DataVizBox } from '../DataVizBox'; import { DataPoint, createLineGenerator, drawLine, minMaxRange, scaleCreatorNumerical, xAxisCreator, xGrid, yAxisCreator, yGrid } from '../utils/D3Utils'; import './Chart.scss'; export interface SelectedDataPoint extends DataPoint { elem?: d3.Selection; } export interface LineChartProps { vizBox: DataVizBox; Document: Doc; layoutDoc: Doc; axes: string[]; titleCol: string; records: { [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 ObservableReactComponent { private _disposers: { [key: string]: IReactionDisposer } = {}; private _lineChartRef: HTMLDivElement | null = null; private _lineChartSvg: d3.Selection | undefined; @observable _currSelected: DataPoint | undefined = undefined; // TODO: nda - some sort of mapping that keeps track of the annotated points so we can easily remove when annotations list updates constructor(props: any) { super(props); makeObservable(this); } @computed get titleAccessor() { let titleAccessor: any = 'dataViz_lineChart_title'; if (this._props.axes.length === 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; else if (this._props.axes.length > 0) titleAccessor += this._props.axes[0]; return titleAccessor; } @computed get _tableDataIds() { return !this.parentViz ? this._props.records.map((rec, i) => i) : NumListCast(this.parentViz.dataViz_selectedRows); } // returns all the data records that will be rendered by only returning those records that have been selected by the parent visualization (or all records if there is no parent) @computed get _tableData() { return !this.parentViz ? this._props.records : this._tableDataIds.map(rowId => this._props.records[rowId]); } @computed get _lineChartData() { if (this._props.axes.length <= 1) return []; return this._tableData.map(record => ({ x: Number(record[this._props.axes[0]]), y: Number(record[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 parentViz() { return DocCast(this._props.Document.dataViz_parentViz); } @computed get incomingHighlited() { // return selected x and y axes // otherwise, use the selection of whatever is linked to us const incomingVizBox = DocumentView.getFirstDocumentView(this.parentViz)?.ComponentView as DataVizBox; const highlitedRowIds = NumListCast(incomingVizBox?.layoutDoc?.dataViz_highlitedRows); return this._tableData.filter((record, i) => highlitedRowIds.includes(this._tableDataIds[i])); // get all the datapoints they have selected field set by incoming anchor } @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() { // coloring the selected point if (!this._props.layoutDoc[this.titleAccessor]) this._props.layoutDoc[this.titleAccessor] = this.defaultGraphTitle; if (!this._props.layoutDoc.dataViz_lineChart_selectedData) this._props.layoutDoc.dataViz_lineChart_selectedData = new List(); this._disposers.selector = reaction(() => StrListCast(this._props.layoutDoc.dataViz_lineChart_selectedData).slice(), this.colorSelectedPts, { fireImmediately: true }); } // anything that doesn't need to be recalculated should just be stored as drawCharts (i.e. computed values) and drawChart is gonna iterate over these observables and generate svgs based on that clearAnnotations = () => { const elements = document.querySelectorAll('.datapoint'); for (let i = 0; i < elements.length; i++) { const element = elements[i]; element.classList.remove('brushed'); element.classList.remove('selected'); } }; // 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 ?? ''), }); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this._props.Document); anchor.config_dataVizSelection = this._currSelected ? new List([this._currSelected.x, this._currSelected.y]) : undefined; return anchor; }; private colorSelectedPts = () => { const elements = document.querySelectorAll('.datapoint'); for (let i = 0; i < elements.length; i++) { const dx = Number(elements[i].getAttribute('data-x')); const dy = Number(elements[i].getAttribute('data-y')); const selectedDataBars = StrListCast(this._props.layoutDoc.dataViz_lineChart_selectedData); const selected = selectedDataBars.some(eachSelectedData => { const [sx, sy] = eachSelectedData.split(','); // parse each selected point into x,y return Number(sx) === dx && Number(sy) === dy; }); if (selected) elements[i].classList.add('brushed'); else elements[i].classList.remove('brushed'); } }; @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() { const ax0 = this._props.axes[0]; const ax1 = this._props.axes.length > 1 ? this._props.axes[1] : undefined; if (this._props.axes.length < 2 || !/\d/.test(this._props.records[0][ax0]) || !ax1) { return ax0 + ' Line Chart'; } return ax1 + ' by ' + ax0 + ' Line Chart'; } setupTooltip() { return d3.select(this._lineChartRef).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'); } @action setCurrSelected(d: DataPoint) { let ptWasSelected = false; const selectedDatapoints = Cast(this._props.layoutDoc.dataViz_lineChart_selectedData, listSpec('string'), null); selectedDatapoints?.forEach(eachData => { if (!ptWasSelected) { const [dx, dy] = eachData.split(','); if (Number(dx) === d.x && Number(dy) === d.y) { ptWasSelected = true; const index = selectedDatapoints.indexOf(eachData); selectedDatapoints.splice(index, 1); this._currSelected = undefined; } } }); if (!ptWasSelected) { selectedDatapoints.push(d.x + ',' + d.y); this._currSelected = selectedDatapoints.length > 1 ? undefined : d; } // for filtering child dataviz docs if (this._props.layoutDoc.dataViz_filterSelection) { const selectedRows = Cast(this._props.layoutDoc.dataViz_selectedRows, listSpec('number'), null); this._tableDataIds.forEach(rowID => { if ( Number(this._props.records[rowID][this._props.axes[0]].replace(/\$/g, '').replace(/%/g, '').replace(/, yScale: d3.ScaleLinear, higlightFocusPt: any, tooltip: any) { 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) .on('mouseenter', e => { const d0 = { x: Number(e.target.getAttribute('data-x')), y: Number(e.target.getAttribute('data-y')) }; this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); higlightFocusPt.style('display', null); }) .on('mouseleave', () => { tooltip?.transition().duration(300).style('opacity', 0); }) .on('click', (e: any) => { const d0 = { x: Number(e.target.getAttribute('data-x')), y: Number(e.target.getAttribute('data-y')) }; // find .circle-d1 with data-x = d0.x and data-y = d0.y this.setCurrSelected(d0); this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); }); } } 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).select('svg').remove(); d3.select(this._lineChartRef).select('.tooltip').remove(); let { xMin, xMax, yMin, yMax } = rangeVals; if (xMin === undefined || xMax === undefined || yMin === undefined || yMax === undefined) { return; } // adding svg const { margin } = this._props; const svg = (this._lineChartSvg = d3 .select(this._lineChartRef) .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})`)); let validSecondData; if (this._props.axes.length > 2) { // for when there are 2 lines on the chart const next = this._tableData.map(record => ({ x: Number(record[this._props.axes[0]]), y: Number(record[this._props.axes[2]]) })).sort((a, b) => (a.x < b.x ? -1 : 1)); validSecondData = next.filter(d => { if (!d.x || isNaN(d.x) || !d.y || isNaN(d.y)) return false; return true; }); const secondDataRange = minMaxRange([validSecondData]); if (secondDataRange.xMax! > xMax) xMax = secondDataRange.xMax; if (secondDataRange.yMax! > yMax) yMax = secondDataRange.yMax; if (secondDataRange.xMin! < xMin) xMin = secondDataRange.xMin; if (secondDataRange.yMin! < yMin) yMin = secondDataRange.yMin; } // creating the x and y scales const xScale = scaleCreatorNumerical(xMin!, xMax!, 0, width); const yScale = scaleCreatorNumerical(0, yMax!, height, 0); const lineGen = createLineGenerator(xScale, yScale); // 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); const higlightFocusPt = svg.append('g').style('display', 'none'); higlightFocusPt.append('circle').attr('r', 5).attr('class', 'circle'); const tooltip = this.setupTooltip(); if (validSecondData) { drawLine(svg.append('path'), validSecondData, lineGen, true); this.drawDataPoints(validSecondData, 0, xScale, yScale, higlightFocusPt, tooltip); svg.append('path').attr('stroke', 'red'); // legend const color: any = d3.scaleOrdinal().range(['black', 'blue']).domain([this._props.axes[1], this._props.axes[2]]); svg.selectAll('mydots') .data([this._props.axes[1], this._props.axes[2]]) .enter() .append('circle') .attr('cx', 5) .attr('cy', (d, i) => -30 + i * 15) .attr('r', 7) .style('fill', d => color(d)); svg.selectAll('mylabels') .data([this._props.axes[1], this._props.axes[2]]) .enter() .append('text') .attr('x', 25) .attr('y', (d, i) => -30 + i * 15) .style('fill', d => color(d)) .text(d => d) .attr('text-anchor', 'left') .style('alignment-baseline', 'middle'); } // get valid data points const data = dataSet[0]; const keys = Object.keys(data[0]); const validData = data.filter(d => !keys.some(key => isNaN(d[key]))); // draw the plot line drawLine(svg.append('path'), validData, lineGen, false); // draw the datapoint circle this.drawDataPoints(validData, 0, xScale, yScale, higlightFocusPt, tooltip); // 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', -30) .attr('height', 20) .attr('width', 20) .style('text-anchor', 'middle') .text(this._props.axes[1]); this.colorSelectedPts(); }; 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() { const selectedPt = this._currSelected ? `{ ${this._props.axes[0]}: ${this._currSelected.x} ${this._props.axes[1]}: ${this._currSelected.y} }` : 'none'; let selectedTitle = ''; if (this._currSelected && this._props.titleCol) { selectedTitle += '\n' + this._props.titleCol + ': '; this._tableData.forEach(each => { let mapThisEntry = false; if (this._currSelected?.x === each[this._props.axes[0]] && this._currSelected?.y === each[this._props.axes[1]]) mapThisEntry = true; else if (this._currSelected?.y === each[this._props.axes[0]] && this._currSelected?.x === each[this._props.axes[1]]) mapThisEntry = true; if (mapThisEntry) selectedTitle += each[this._props.titleCol] + ', '; }); selectedTitle = selectedTitle.slice(0, -1).slice(0, -1); } if (this._lineChartData.length > 0 || !this.parentViz || this.parentViz.length === 0) { return this._props.axes.length >= 2 && /\d/.test(this._props.records[0][this._props.axes[0]]) && /\d/.test(this._props.records[0][this._props.axes[1]]) ? (
{ this._props.layoutDoc[this.titleAccessor] = val as string; }), 'Change Graph Title' )} color="black" size={Size.LARGE} fillWidth />
{ this._lineChartRef = r; this.drawChart([this._lineChartData], this.rangeVals, this.width, this.height); }} /> {selectedPt !== 'none' ? (
{`Selected: ${selectedPt}`} {`${selectedTitle}`}
) : null}
) : ( first use table view to select two numerical axes to plot ); } 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.
); } }