diff options
author | bobzel <zzzman@gmail.com> | 2023-04-07 10:42:16 -0400 |
---|---|---|
committer | bobzel <zzzman@gmail.com> | 2023-04-07 10:42:16 -0400 |
commit | 986c5bdc899954c4609f71405d3334147be721fe (patch) | |
tree | 98fb4b67e0438f29e5372e9e35fa0cf8f00798bb | |
parent | e9633592cdd11df4d4d240dc42c254f60d16b572 (diff) |
experimetnal changes to datavizbox to allow brushing data items and better highlighting of selections. Also working on drawing link lines between chart aliases.
-rw-r--r-- | src/client/views/nodes/DataVizBox/DataVizBox.tsx | 11 | ||||
-rw-r--r-- | src/client/views/nodes/DataVizBox/components/Chart.scss | 20 | ||||
-rw-r--r-- | src/client/views/nodes/DataVizBox/components/LineChart.tsx | 105 | ||||
-rw-r--r-- | src/client/views/nodes/DataVizBox/components/TableBox.tsx | 98 | ||||
-rw-r--r-- | src/fields/Doc.ts | 1 |
5 files changed, 170 insertions, 65 deletions
diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 0ce589a13..aaa8c3c53 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -13,7 +13,7 @@ import { LineChart } from './components/LineChart'; import { TableBox } from './components/TableBox'; import './DataVizBox.scss'; -enum DataVizView { +export enum DataVizView { TABLE = 'table', LINECHART = 'lineChart', } @@ -27,7 +27,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // 2 ways of doing it // @observable private pairs: { [key: string]: number | string | undefined }[] = []; // @observable private pairs: { [key: string]: FieldResult }[] = []; - @observable private pairs: { [key: string]: string }[] = []; + @observable pairs: { [key: string]: string }[] = []; private _chartRenderer: LineChart | undefined; // // another way would be store a schema that defines the type of data we are expecting from an imported doc @@ -71,6 +71,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const anchor = this._chartRenderer?.getAnchor(pinProps) ?? Docs.Create.TextanchorDocument({ + unrendered: true, // when we clear selection -> we should have it so chartBox getAnchor returns undefined // this is for when we want the whole doc (so when the chartBox getAnchor returns without a marker) /*put in some options*/ @@ -89,12 +90,12 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { selectAxes = (axes: string[]) => (this.layoutDoc.dataVizAxes = new List<string>(axes)); @computed get selectView() { - const width = NumCast(this.rootDoc._width) * 0.9; + const width = this.props.PanelWidth() * 0.9; const height = (this.props.PanelHeight() - 32) /* height of 'change view' button */ * 0.9; const margin = { top: 10, right: 50, bottom: 50, left: 50 }; // prettier-ignore switch (this.dataVizView) { - case DataVizView.TABLE: return <TableBox pairs={this.pairs} axes={this.axes} selectAxes={this.selectAxes}/>; + 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} />; } } @@ -136,7 +137,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { { passive: false } ) }> - <button onClick={e => this.changeViewHandler(e)}>Change View</button> + <button onClick={e => this.changeViewHandler(e)}>{this.dataVizView === DataVizView.TABLE ? DataVizView.LINECHART : DataVizView.TABLE}</button> {this.selectView} </div> ); diff --git a/src/client/views/nodes/DataVizBox/components/Chart.scss b/src/client/views/nodes/DataVizBox/components/Chart.scss index 2d6c5f0f2..c8daf7c90 100644 --- a/src/client/views/nodes/DataVizBox/components/Chart.scss +++ b/src/client/views/nodes/DataVizBox/components/Chart.scss @@ -8,8 +8,28 @@ // change the color of the circle element to be red fill: red; } +.focus-selected, +.selected { + // change the color of the circle element to be red + fill: lightblue; + position: absolute; + transform-box: fill-box; + scale: 2; + transform-origin: center; +} +.focus { + fill: transparent; + outline: black solid 1px; + border-radius: 100%; +} +.focus-selected { + scale: 1; + outline: black solid 1px; + border-radius: 100%; +} .chart-container { display: flex; flex-direction: column; align-items: center; + cursor: default; } diff --git a/src/client/views/nodes/DataVizBox/components/LineChart.tsx b/src/client/views/nodes/DataVizBox/components/LineChart.tsx index e6a06a454..6a223e683 100644 --- a/src/client/views/nodes/DataVizBox/components/LineChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/LineChart.tsx @@ -6,11 +6,15 @@ import * as d3 from 'd3'; import { Doc, DocListCast } from '../../../../../fields/Doc'; import { List } from '../../../../../fields/List'; import { listSpec } from '../../../../../fields/Schema'; -import { Cast } from '../../../../../fields/Types'; +import { Cast, DocCast, NumCast } 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'; +import { LinkManager } from '../../../../util/LinkManager'; +import { DocumentManager } from '../../../../util/DocumentManager'; +import { Id } from '../../../../../fields/FieldSymbols'; +import { DataVizBox } from '../DataVizBox'; export interface DataPoint { x: number; @@ -40,7 +44,7 @@ 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; - private _rangeVals: { xMin?: number; xMax?: number;yMin?: number;yMax?: number;}= {}; + private _rangeVals: { xMin?: number; xMax?: number; yMin?: number; yMax?: number } = {}; @observable _currSelected: SelectedDataPoint | undefined = undefined; @observable _lineChartData: 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 @@ -51,9 +55,11 @@ export class LineChart extends React.Component<LineChartProps> { componentDidMount = () => { this._disposers.chartdata = reaction( () => this.props.axes.slice(), - axes => {if (axes.length > 1) { - this._lineChartData = [this.props.pairs?.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)] - }}, + axes => { + if (axes.length > 1) { + this._lineChartData = [this.props.pairs?.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))]; + } + }, { fireImmediately: true } ); this._disposers.chartData = reaction( @@ -78,12 +84,38 @@ export class LineChart extends React.Component<LineChartProps> { }, { fireImmediately: true } ); + this._disposers.highlights = reaction( + () => ({ + selected: this._currSelected, + pairs: LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) + .filter(link => link.anchor1 !== this.props.rootDoc) // all links that are pointing to this node + .map(link => DocCast(link.anchor1)) // get the documents 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(), + }), + ({ selected, pairs }) => { + this.clearAnnoations(); + selected && this.drawAnnotations(Number(selected.x), Number(selected.y), true); + pairs.forEach(pair => 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 + clearAnnoations = () => { + const elements = document.querySelectorAll('.datapoint'); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + element.classList.remove('highlight'); + element.classList.remove('selected'); + } + }; // gets called whenever the "data-annotations" fields gets updated - drawAnnotations(dataX: number, dataY: number) { + 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 @@ -94,14 +126,13 @@ export class LineChart extends React.Component<LineChartProps> { const x = element.getAttribute('data-x'); const y = element.getAttribute('data-y'); if (x === dataX.toString() && y === dataY.toString()) { - element.classList.add('highlight'); + element.classList.add(selected ? 'selected' : '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 @@ -115,7 +146,7 @@ export class LineChart extends React.Component<LineChartProps> { return true; } if (this._currSelected) { - this._currSelected = undefined; + this.setCurrSelected(); return true; } return false; @@ -123,9 +154,7 @@ export class LineChart extends React.Component<LineChartProps> { // 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, - }); + 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; @@ -140,7 +169,7 @@ export class LineChart extends React.Component<LineChartProps> { } setupTooltip() { - const tooltip = d3 + return d3 .select(this._lineChartRef.current) .append('div') .attr('class', 'tooltip') @@ -150,14 +179,15 @@ export class LineChart extends React.Component<LineChartProps> { .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) { + setCurrSelected(x?: number, y?: number) { // TODO: nda - get rid of svg element in the list? - this._currSelected = { x, y }; + 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>) { @@ -215,32 +245,29 @@ export class LineChart extends React.Component<LineChartProps> { // 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 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 = bisect(data, xScale.invert(xPos - 25)); // shift x by -25 so that you can reach points on the left-side axis + 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]; - 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(() => `<b>(${d0.x},${d0.y})</b>`) // text content for tooltip - .style('pointer-events', 'none') - .style('transform', `translate(${xScale(d0.x) - this.width / 2 + 12}px,${yScale(d0.y) + 30}px)`); + 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 - 25)); // shift x by -25 so that you can reach points on the left-side axis + 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._currSelected = { x: d0.x, y: d0.y, elem: selected }; + this.setCurrSelected(d0.x, d0.y); + this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); }); svg.append('rect') @@ -250,17 +277,33 @@ export class LineChart extends React.Component<LineChartProps> { .attr('fill', 'none') .attr('translate', `translate(${margin.left}, ${-(margin.top + margin.bottom)})`) .style('opacity', 0) - .on('mouseover', () => focus.style('display', null)) + .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 ? 'focus-selected' : 'focus'); + 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' : `Curr Selected: ${selectedPt}`}</span> + <span> {this.props.axes.length < 2 ? 'first use table view to select two axes to plot' : `Selected: ${selectedPt}`}</span> </div> ); } diff --git a/src/client/views/nodes/DataVizBox/components/TableBox.tsx b/src/client/views/nodes/DataVizBox/components/TableBox.tsx index adefe90cd..0d69ac890 100644 --- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx +++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx @@ -1,12 +1,19 @@ import { action, computed } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { emptyFunction, returnFalse, setupMoveUpEvents } from '../../../../../Utils'; +import { AnimationSym, Doc } from '../../../../../fields/Doc'; +import { Id } from '../../../../../fields/FieldSymbols'; +import { List } from '../../../../../fields/List'; +import { emptyFunction, returnFalse, setupMoveUpEvents, Utils } from '../../../../../Utils'; +import { DragManager } from '../../../../util/DragManager'; +import { DocumentView } from '../../DocumentView'; +import { DataVizView } from '../DataVizBox'; interface TableBoxProps { pairs: { [key: string]: any }[]; selectAxes: (axes: string[]) => void; axes: string[]; + docView?: () => DocumentView | undefined; } @observer @@ -20,39 +27,72 @@ export class TableBox extends React.Component<TableBoxProps> { <table className="table"> <thead> <tr className="table-row"> - {this.columns.map(col => ( - <th - style={{ color: this.props.axes.slice().reverse().lastElement() === col ? 'green' : this.props.axes.lastElement() === col ? 'red' : undefined, fontWeight: this.props.axes.includes(col) ? 'bolder' : 'normal' }} - onPointerDown={e => - setupMoveUpEvents( - {}, - e, - returnFalse, - emptyFunction, - action(e => { - const newAxes = this.props.axes; - if (newAxes.includes(col)) { - newAxes.splice(newAxes.indexOf(col), 1); - } else if (newAxes.length >= 1) { - newAxes[1] = col; - } else { - newAxes[0] = col; - } - this.props.selectAxes(newAxes); - }) - ) - }> - {col} - </th> - ))} + {this.columns + .filter(col => !col.startsWith('select')) + .map(col => { + const header = React.createRef<HTMLElement>(); + return ( + <th + ref={header as any} + style={{ + color: this.props.axes.slice().reverse().lastElement() === col ? 'green' : this.props.axes.lastElement() === col ? 'red' : undefined, + fontWeight: this.props.axes.includes(col) ? 'bolder' : 'normal', + }} + onPointerDown={e => { + const downX = e.clientX; + const downY = e.clientY; + setupMoveUpEvents( + {}, + e, + e => { + const sourceAnchorCreator = () => this.props.docView?.()!.rootDoc!; + const targetCreator = (annotationOn: Doc | undefined) => { + const alias = Doc.MakeAlias(this.props.docView?.()!.rootDoc!); + alias._dataVizView = DataVizView.LINECHART; + alias._dataVizAxes = new List<string>([col, col]); + alias.annotationOn = annotationOn; //this.props.docView?.()!.rootDoc!; + return alias; + }; + if (this.props.docView?.() && !Utils.isClick(e.clientX, e.clientY, downX, downY, Date.now())) { + DragManager.StartAnchorAnnoDrag([header.current!], new DragManager.AnchorAnnoDragData(this.props.docView()!, sourceAnchorCreator, targetCreator), downX, downY, { + dragComplete: e => { + if (!e.aborted && e.annoDragData && e.annoDragData.linkSourceDoc && e.annoDragData.dropDocument && e.linkDocument) { + e.linkDocument.linkDisplay = true; + // e.annoDragData.linkSourceDoc.followLinkToggle = e.annoDragData.dropDocument.annotationOn === this.props.rootDoc; + // e.annoDragData.linkSourceDoc.followLinkZoom = false; + } + }, + }); + return true; + } + return false; + }, + emptyFunction, + action(e => { + const newAxes = this.props.axes; + if (newAxes.includes(col)) { + newAxes.splice(newAxes.indexOf(col), 1); + } else if (newAxes.length >= 1) { + newAxes[1] = col; + } else { + newAxes[0] = col; + } + this.props.selectAxes(newAxes); + }) + ); + }}> + {col} + </th> + ); + })} </tr> </thead> <tbody> - {this.props.pairs?.map(p => { + {this.props.pairs?.map((p, i) => { return ( - <tr className="table-row"> + <tr className="table-row" onClick={action(e => (p['select' + this.props.docView?.()?.rootDoc![Id]] = !p['select' + this.props.docView?.()?.rootDoc![Id]]))}> {this.columns.map(col => ( - <td>{p[col]}</td> + <td style={{ fontWeight: p['select' + this.props.docView?.()?.rootDoc![Id]] ? 'bold' : '' }}>{p[col]}</td> ))} </tr> ); diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index cc024d83a..e89f5db52 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -1335,6 +1335,7 @@ export namespace Doc { } export function LinkEndpoint(linkDoc: Doc, anchorDoc: Doc) { + if (linkDoc.anchor2 === anchorDoc || (linkDoc.anchor2 as Doc).annotationOn) return '2'; return Doc.AreProtosEqual(anchorDoc, (linkDoc.anchor1 as Doc).annotationOn as Doc) || Doc.AreProtosEqual(anchorDoc, linkDoc.anchor1 as Doc) ? '1' : '2'; } |