diff options
Diffstat (limited to 'src')
3 files changed, 336 insertions, 2 deletions
diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 0fe24fe8d..38edf2dd9 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -12,10 +12,12 @@ import { PinProps } from '../trails'; import { LineChart } from './components/LineChart'; import { TableBox } from './components/TableBox'; import './DataVizBox.scss'; +import { Histogram } from './components/Histogram'; export enum DataVizView { TABLE = 'table', LINECHART = 'lineChart', + HISTOGRAM = 'histogram' } @observer @@ -100,6 +102,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { switch (this.dataVizView) { case DataVizView.TABLE: return <TableBox pairs={this.pairs} axes={this.axes} docView={this.props.DocumentView} selectAxes={this.selectAxes}/>; case DataVizView.LINECHART: return <LineChart ref={r => (this._chartRenderer = r ?? undefined)} height={height} width={width} fieldKey={this.fieldKey} margin={margin} rootDoc={this.rootDoc} axes={this.axes} pairs={this.pairs} dataDoc={this.dataDoc} />; + case DataVizView.HISTOGRAM: return <Histogram height={height} width={width} fieldKey={this.fieldKey} margin={margin} rootDoc={this.rootDoc} axes={this.axes} pairs={this.pairs} dataDoc={this.dataDoc} />; } } @computed get dataUrl() { @@ -142,7 +145,10 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { { passive: false } ) }> - <button onClick={e => this.changeViewHandler(e)}>{this.dataVizView === DataVizView.TABLE ? DataVizView.LINECHART : DataVizView.TABLE}</button> + {/* <button onClick={e => this.changeViewHandler(e)}>{this.dataVizView === DataVizView.TABLE ? DataVizView.LINECHART : DataVizView.TABLE}</button> */} + <button onClick={e => this.layoutDoc._dataVizView = DataVizView.TABLE}> TABLE </button> + <button onClick={e => this.layoutDoc._dataVizView = DataVizView.LINECHART}> LINE CHART </button> + <button onClick={e => this.layoutDoc._dataVizView = DataVizView.HISTOGRAM}> HISTOGRAM </button> {this.selectView} </div> ); diff --git a/src/client/views/nodes/DataVizBox/components/Histogram.tsx b/src/client/views/nodes/DataVizBox/components/Histogram.tsx new file mode 100644 index 000000000..cb0b8cd9a --- /dev/null +++ b/src/client/views/nodes/DataVizBox/components/Histogram.tsx @@ -0,0 +1,326 @@ +import { observer } from "mobx-react"; +import { Doc, DocListCast } from "../../../../../fields/Doc"; +import * as React from 'react'; +import * as d3 from 'd3'; +import { IReactionDisposer, action, computed, observable, reaction } from "mobx"; +import { LinkManager } from "../../../../util/LinkManager"; +import { Cast, DocCast, StrCast } from "../../../../../fields/Types"; +import { useMemo } from "react"; +import { DataPoint, SelectedDataPoint } from "./LineChart"; +import { createLineGenerator, drawLine, minMaxRange, scaleCreatorNumerical, xAxisCreator, xGrid, yAxisCreator, yGrid } from "../utils/D3Utils"; +import { DocumentManager } from "../../../../util/DocumentManager"; +import { Id } from "../../../../../fields/FieldSymbols"; +import { DataVizBox } from "../DataVizBox"; +import { listSpec } from "../../../../../fields/Schema"; +import { PinProps, PresBox } from "../../trails"; +import { Docs } from "../../../../documents/Documents"; +import { List } from "../../../../../fields/List"; +import './Chart.scss'; +import { isNaN } from "lodash"; + +export interface HistogramProps { + rootDoc: 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 Histogram extends React.Component<HistogramProps> { + + private _disposers: { [key: string]: IReactionDisposer } = {}; + private _histogramRef: React.RefObject<HTMLDivElement> = React.createRef(); + private _histogramSvg: d3.Selection<SVGGElement, unknown, null, undefined> | 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 _histogramData() { + if (this.props.axes.length < 1) return []; + if (this.props.axes.length < 2) { + var ax0 = this.props.axes[0]; + return this.props.pairs + ?.filter(pair => (!this.incomingLinks.length ? true : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + .map(pair => ({ [ax0]: (pair[this.props.axes[0]])})) + }; + var ax0 = this.props.axes[0]; + var ax1 = this.props.axes[1]; + return this.props.pairs + ?.filter(pair => (!this.incomingLinks.length ? true : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + .map(pair => ({ [ax0]: (pair[this.props.axes[0]]), [ax1]: (pair[this.props.axes[1]]) })) + // .sort((a, b) => (a[ax0] < b[ax0] ? -1 : 1)); + return this.props.pairs + ?.filter(pair => (!this.incomingLinks.length ? true : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + .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 incomingLinks() { + return LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) // out of all links + .filter(link => link.link_anchor_1 !== this.props.rootDoc) // 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: { [x: string]: any; }) => pair['select' + dvb.rootDoc[Id]])) // get all the datapoints they have selected field set by incoming anchor + .lastElement(); + } + componentWillUnmount() { + Array.from(Object.keys(this._disposers)).forEach(key => this._disposers[key]()); + } + componentDidMount = () => { + this._disposers.chartData = reaction( + () => ({ dataSet: this._histogramData, w: this.width, h: this.height }), + ({ dataSet, w, h }) => { + if (dataSet) { + this.drawChart(dataSet, w, h); + + // redraw annotations when the chart data has changed, or the local or inherited selection has changed + this.clearAnnotations(); + this._currSelected && this.drawAnnotations(Number(this._currSelected.x), Number(this._currSelected.y), true); + this.incomingSelected?.forEach((pair: any) => this.drawAnnotations(Number(pair[this.props.axes[0]]), Number(pair[this.props.axes[1]]))); + } + }, + { 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 + this.clearAnnotations(); + selected && this.drawAnnotations(Number(selected.x), Number(selected.y), true); + incomingSelected?.forEach((pair: any) => this.drawAnnotations(Number(pair[this.props.axes[0]]), Number(pair[this.props.axes[1]]))); + }, + { 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'); + } + }; + // gets called whenever the "data_annotations" fields gets updated + drawAnnotations = (dataX: number, dataY: number, selected?: boolean) => { + // 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(selected ? 'selected' : 'brushed'); + } + // TODO: nda - this remove highlight code should go where we remove the links + // } else { + // } + } + }; + + removeAnnotations(dataX: number, dataY: number) { + // loop through and remove any annotations that no longer exist + } + + @action + restoreView = (data: Doc) => { + const coords = Cast(data.presDataVizSelection, 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.dataDoc); + anchor.presDataVizSelection = this._currSelected ? new List<number>([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; + } + + setupTooltip() { + return d3 + .select(this._histogramRef.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? + 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)); + this.props.pairs.forEach(pair => (pair.selected = pair[this.props.axes[0]] === x && pair[this.props.axes[1]] === y ? true : undefined)); + } + + drawDataPoints(data: DataPoint[], idx: number, xScale: d3.ScaleLinear<number, number, never>, yScale: d3.ScaleLinear<number, number, never>) { + if (this._histogramSvg) { + const circleClass = '.circle-' + idx; + this._histogramSvg + .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); + } + } + + drawChart = (dataSet: any, width: number, height: number) => { + + var validData = dataSet.filter((d: { [x: string]: unknown; }) => { + var valid = true; + Object.keys(dataSet[0]).map(key => { + if (!d[key] || Number.isNaN(d[key])) valid = false; + }) + return valid; + }) + var field = Object.keys(dataSet[0])[0] + const data = validData.map((d: { [x: string]: any; }) => { + if (+d[field]) {return +d[field] } + return d[field] + }) + + let uniqueArr = [...new Set(data)] + var numBins = uniqueArr.length + if (+d3.max(data)!) numBins = +d3.max(data)! + // var numBins = (+d3.max(data)!)? +d3.max(data)! : 3; + + const svg = d3.select(this._histogramRef.current) + .append("svg") + .attr("width", width + this.props.margin.right + this.props.margin.left) + .attr("height", height + this.props.margin.top + this.props.margin.bottom) + .append("g") + .attr("transform", + "translate(" + this.props.margin.left + "," + this.props.margin.top + ")"); + + var x = d3.scaleLinear() + .domain([0, numBins]) + .range([0, width]); + var xAxis = d3.axisBottom(x) + .ticks(numBins) + var translateXAxis = 0; + + var histogram = d3.histogram() + .value(function(d) {return d}) + .domain([0, numBins]) + .thresholds(x.ticks(numBins)) + var bins = histogram(data) + if (!+d3.max(data)!) { // if the data is strings rather than numbers + uniqueArr.sort() + for (let i=0; i<data.length; i++){ + var index = 0 + for (let j=0; j<uniqueArr.length; j++){ + if (uniqueArr[j] == data[i]){ + index = j; + } + } + bins[index].push(data[i]) + } + bins.forEach(d => d.x0 = d.x0!+1) + xAxis = d3.axisBottom(x) + .ticks(numBins) + .tickValues([0, 1, 2, 3, 4]) + .tickFormat( i => uniqueArr[i]) + .tickPadding(10) + translateXAxis = (width/numBins) / 2; + } + + var y = d3.scaleLinear() + .range([height, 0]); + y.domain([0, d3.max(bins, function(d) { return d.length; })!]); + var yAxis = d3.axisLeft(y) + .ticks(d3.max(bins, function(d) { return d.length; })!) + svg.append("g") + .call(yAxis); + svg.append("g") + .attr("transform", "translate(" + translateXAxis + ", " + height + ")") + .call(xAxis) + + d3.format('.0f') + + svg.selectAll("rect") + .data(bins) + .enter() + .append("rect") + .attr("x", 1) + .attr("transform", function(d) { return "translate(" + x(d.x0! - 1) + "," + y(d.length) + ")"; }) + // .attr("width", function(d) { return x(d.x1!) - x(d.x0! ) ; }) + .attr("width", width/(numBins)) + .attr("height", function(d) { return height - y(d.length); }) + .attr("style", "outline: thin solid black;") + .style("fill", "#69b3a2") + }; + + render() { + + return ( + this.props.axes.length >= 1 ? ( + <div ref={this._histogramRef} className="chart-container"> + <span> {`Selected: ${Object.keys(this._histogramData[0])[0]}`}</span> + </div> + ) : <span className="chart-container"> {'first use table view to select an axis to plot'}</span> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/components/LineChart.tsx b/src/client/views/nodes/DataVizBox/components/LineChart.tsx index 6b564b0c9..289cecb6b 100644 --- a/src/client/views/nodes/DataVizBox/components/LineChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/LineChart.tsx @@ -20,7 +20,7 @@ export interface DataPoint { x: number; y: number; } -interface SelectedDataPoint extends DataPoint { +export interface SelectedDataPoint extends DataPoint { elem?: d3.Selection<d3.BaseType, unknown, SVGGElement, unknown>; } export interface LineChartProps { @@ -60,6 +60,8 @@ export class LineChart extends React.Component<LineChartProps> { .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) |