import { action, computed, IReactionDisposer, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; // import d3 import * as d3 from 'd3'; import { Doc, DocListCast } from '../../../../../fields/Doc'; import { listSpec } from '../../../../../fields/Schema'; import { Cast } from '../../../../../fields/Types'; import { Docs } from '../../../../documents/Documents'; import { PinProps, PresBox } from '../../trails'; import { createLineGenerator, drawLine, minMaxRange, scaleCreatorNumerical, xAxisCreator, xGrid, yAxisCreator, yGrid } from '../utils/D3Utils'; import './Chart.scss'; type minMaxRange = { xMin: number | undefined; xMax: number | undefined; yMin: number | undefined; yMax: number | undefined; }; export interface DataPoint { x: number; y: number; } interface SelectedDataPoint extends DataPoint { elem?: d3.Selection; } export interface LineChartData { xLabel: string; yLabel: string; dataSet: DataPoint[][]; } export interface LineChartProps { rootDoc: Doc; pairs: { x: number; y: number }[]; 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 } = {}; @observable private _x: number = 0; @observable private _y: number = 0; @observable private _currSelected: SelectedDataPoint | undefined = undefined; @observable private _lineChartData: LineChartData | undefined = undefined; // create ref for the div private _lineChartRef: React.RefObject = React.createRef(); private _lineChartSvg: d3.Selection | undefined; private _rangeVals: minMaxRange = { xMin: undefined, xMax: undefined, yMin: undefined, yMax: undefined, }; // TODO: nda - some sort of mapping that keeps track of the annotated points so we can easily remove when annotations list updates componentWillUnmount() { Array.from(Object.keys(this._disposers)).forEach(key => this._disposers[key]()); } componentDidMount = () => { this._disposers.chartData = reaction( () => ({ dataSet: this._lineChartData?.dataSet, w: this.props.width, h: this.props.height }), vals => { if (vals.dataSet) { this._rangeVals = minMaxRange(vals.dataSet); this.drawChart(vals.dataSet); } }, { 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.generateChartData(); }; @action generateChartData() { this._lineChartData = { xLabel: 'x', yLabel: 'y', // TODO: nda - add actual support for multiple sets of data dataSet: [this.props.pairs?.map(pair => ({ x: pair.x, y: pair.y }))], }; } // 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 // gets called whenever the "data-annotations" fields gets updated drawAnnotations(dataX: number, dataY: number) { // TODO: nda - can optimize this by having some sort of mapping of the x and y values to the individual circle elements // loop through all html elements with class .circle-d1 and find the one that has "data-x" and "data-y" attributes that match the dataX and dataY // if it exists, then highlight it // if it doesn't exist, then remove the highlight 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 (x === dataX.toString() && y === dataY.toString()) { element.classList.add('highlight'); } // TODO: nda - this remove highlight code should go where we remove the links // } else { // element.classList.remove('highlight'); // } } } removeAnnotations(dataX: number, dataY: number) { // loop through and remove any annotations that no longer exist } restoreView = (data: Doc) => { const coords = Cast(data.presDataViz, listSpec('number'), null); if ((coords && this._currSelected?.x !== coords[0]) || this._currSelected?.y !== coords[1]) { this.setCurrSelected(coords[0], coords[1]); 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.TextanchorDocument({ title: 'line doc selection' + this._currSelected?.x, }); PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), dataviz: this._currSelected ? [this._currSelected.x, this._currSelected.y] : undefined } }, this.props.dataDoc); 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; } setupTooltip() { const tooltip = 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'); return tooltip; } // TODO: nda - use this everyewhere we update currSelected? @action setCurrSelected(x: number, y: number) { // TODO: nda - get rid of svg element in the list? this._currSelected = { x, y }; } 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: DataPoint[][]) => { // 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 } = this._rangeVals; if (xMin === undefined || xMax === undefined || yMin === undefined || yMax === undefined) { return; } // creating the x and y scales const xScale = scaleCreatorNumerical(xMin, xMax, 0, this.width); const yScale = scaleCreatorNumerical(0, yMax, this.height, 0); // adding svg const margin = this.props.margin; const svg = (this._lineChartSvg = d3 .select(this._lineChartRef.current) .append('svg') .attr('width', `${this.width + margin.right + margin.left}`) .attr('height', `${this.height + margin.top + margin.bottom}`) .append('g') .attr('transform', `translate(${margin.left}, ${margin.top})`)); // create x and y grids xGrid(svg.append('g'), this.height, xScale); yGrid(svg.append('g'), this.width, yScale); xAxisCreator(svg.append('g'), this.height, xScale); yAxisCreator(svg.append('g'), this.width, yScale); // draw the plot line const data = dataSet[0]; const lineGen = createLineGenerator(xScale, yScale); drawLine(svg.append('path'), data, lineGen); // draw the datapoint circle this.drawDataPoints(data, 0, xScale, yScale); const focus = svg.append('g').attr('class', 'focus').style('display', 'none'); focus.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 = bisect(data, xScale.invert(xPos - 25)); // shift x by -25 so that you can reach points on the left-side axis const d0 = data[x0]; this._x = d0.x; this._y = d0.y; focus.attr('transform', `translate(${xScale(d0.x)},${yScale(d0.y)})`); 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 / 2 + 12}px,${yScale(d0.y) + 30}px)`); }); 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 - 25)); // shift x by -25 so that you can reach points on the left-side axis const d0 = data[x0]; this._x = d0.x; this._y = d0.y; // 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._currSelected = { x: d0.x, y: d0.y, elem: selected }; }); svg.append('rect') .attr('class', 'overlay') .attr('width', this.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', () => focus.style('display', null)) .on('mouseout', () => tooltip.transition().duration(300).style('opacity', 0)) .on('mousemove', mousemove) .on('click', onPointClick); }; render() { return (
Curr Selected: {this._currSelected ? `x: ${this._currSelected.x} y: ${this._currSelected.y}` : 'none'}
); } }