aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/DataVizBox/components/LineChart.tsx
diff options
context:
space:
mode:
authorbobzel <zzzman@gmail.com>2023-04-17 09:37:16 -0400
committerbobzel <zzzman@gmail.com>2023-04-17 09:37:16 -0400
commit6a9e80de419af14bece7a48e55edc1543d69f20f (patch)
tree71ae1b819bc4f7fdb699ae90c035eb86275c5006 /src/client/views/nodes/DataVizBox/components/LineChart.tsx
parent0a38e3f91f4f85f07fdbb7575ceb678032dcdfe9 (diff)
parent8127616d06b4db2b29de0b13068810fd19e77b5e (diff)
Merge branch 'master' into james-server-stats
Diffstat (limited to 'src/client/views/nodes/DataVizBox/components/LineChart.tsx')
-rw-r--r--src/client/views/nodes/DataVizBox/components/LineChart.tsx319
1 files changed, 319 insertions, 0 deletions
diff --git a/src/client/views/nodes/DataVizBox/components/LineChart.tsx b/src/client/views/nodes/DataVizBox/components/LineChart.tsx
new file mode 100644
index 000000000..777bf2f66
--- /dev/null
+++ b/src/client/views/nodes/DataVizBox/components/LineChart.tsx
@@ -0,0 +1,319 @@
+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 { Id } from '../../../../../fields/FieldSymbols';
+import { List } from '../../../../../fields/List';
+import { listSpec } from '../../../../../fields/Schema';
+import { Cast, DocCast } 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';
+
+export interface DataPoint {
+ x: number;
+ y: number;
+}
+interface SelectedDataPoint extends DataPoint {
+ elem?: d3.Selection<d3.BaseType, unknown, SVGGElement, unknown>;
+}
+export interface LineChartProps {
+ 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 LineChart extends React.Component<LineChartProps> {
+ private _disposers: { [key: string]: IReactionDisposer } = {};
+ private _lineChartRef: React.RefObject<HTMLDivElement> = React.createRef();
+ private _lineChartSvg: 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 _lineChartData() {
+ if (this.props.axes.length <= 1) return [];
+ 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.anchor1 !== this.props.rootDoc) // get links where this chart doc is the target of the link
+ .map(link => DocCast(link.anchor1)); // then return the source of the link
+ }
+ @computed get incomingSelected() {
+ 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);
+ // 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.TextanchorDocument({ title: 'line doc selection' + this._currSelected?.x, unrendered: true });
+ 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._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?
+ 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._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[][], 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('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);
+
+ // 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 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);
+ };
+
+ private updateTooltip(
+ higlightFocusPt: d3.Selection<SVGGElement, unknown, null, undefined>,
+ xScale: d3.ScaleLinear<number, number, never>,
+ d0: DataPoint,
+ yScale: d3.ScaleLinear<number, number, never>,
+ tooltip: d3.Selection<HTMLDivElement, unknown, null, undefined>
+ ) {
+ 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(() => `<b>(${d0.x},${d0.y})</b>`) // text content for tooltip
+ .style('pointer-events', 'none')
+ .style('transform', `translate(${xScale(d0.x) - this.width / 2}px,${yScale(d0.y) - 30}px)`);
+ }
+
+ render() {
+ const selectedPt = this._currSelected ? `x: ${this._currSelected.x} y: ${this._currSelected.y}` : 'none';
+ return (
+ <div ref={this._lineChartRef} className="chart-container">
+ <span> {this.props.axes.length < 2 ? 'first use table view to select two axes to plot' : `Selected: ${selectedPt}`}</span>
+ </div>
+ );
+ }
+}