Skip to content

Commit

Permalink
Connected scatter plots (#277)
Browse files Browse the repository at this point in the history
* First iteration of connected scatter plot lines

* Added colored lines and seperated lines into series

* Resize / toggle visibility working with correct redraw (using line reset atm);  todo: fix .exit() / .merge() calls

* Fixed closed paths; checkout d3 curve types

* Added working mouseover and mousemove highlight for connected line series

* wip chartDataOptions refactor; moving from chartOptions to chartDataOptions

* Working pointConnectionMeasure chartData option to specify measure by which to sort connected points

* Added interpolation chartOptionto connected scatterplot

* Fixed d3 update pattern for connected scatter plots

* Updated connected scatter plot test case; Fixed transition bug;

* Updated scatter plot docs and test case

* Remove console log

* moved linewrapper below pointwrapper

Co-authored-by: Cory Crowley <cocrowle@microsoft.com>
  • Loading branch information
ccrowley96 and Cory Crowley committed Nov 9, 2020
1 parent 5f8ff6b commit 9198d4a
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 15 deletions.
26 changes: 17 additions & 9 deletions docs/UX.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,19 @@ heatmap.render(data, chartOptions, chartDataOptionsArray);
### Scatter Plot

**Scatter Plots** are created in the same way as [line charts](#line-chart) and take the same options and data shapes.
However, [spMeasures](#chart-options) **must** be specified as an array of strings in the `chartOptions` object for scatter plots.
However, [spMeasures](#chart-options) **must** be specified as an array of strings in the `chartOptions` object for scatter plots to render.

Scatter plots also have the following optional `chartOptions`:

* [isTemporal](#chart-options) toggles the temporal slider on or off
* [spAxisLabels](#chart-options) creates axis labels for X and Y axis.

```JavaScript
```JavaScript
var tsiClient = new TsiClient();
var scatterPlot = new tsiClient.ux.ScatterPlot(document.getElementById('chart'))
scatterPlot.render(data, chartOptions, chartDataOptionsArray);
```

In addition to [spMeasures](#chart-options), scatter plots also have the following **optional** `chartOptions`:

* [isTemporal](#chart-options) - toggles the temporal slider on or off
* [spAxisLabels](#chart-options) - creates axis labels for X and Y axis.

The following code snippet demonstrates scatter plot-specific chart options:

* [spMeasures](#chart-options) - the first string in the `spMeasures` array is the X axis measure, the second, is the Y axis measure, and the third (optional) string is the data point radius measure.
Expand All @@ -109,11 +109,17 @@ scatterPlot.render(data, {
// ^ X ^ Y ^ R (optional)
isTemporal: true,
// ^ Turn on temporal slider
spAxisLabels:['Termperature', 'Pressure']
spAxisLabels:['Temperature', 'Pressure']
// ^ X axis label ^ Y axis label
});
}
);
```

Connected scatter plots can be rendered using the following **optional** keys in the `chartDataOptions` array.

* [connectPoints](#chart-data-options) - if true, connects the points on the scatter plot
* [pointConnectionMeasure](#chart-data-options) - specifies measure by which to connect points, defaults to time.

**Note**: *scatter plots will not render if `spMeasures` is not specified or **any** of the measures are not found in the [data](#chart-data-shape) as value keys*

### Events Grid
Expand Down Expand Up @@ -340,6 +346,8 @@ Available **Chart Data Options** include:
|`onElementClick`|**(dataGroupName: string, </br> timeSeriesName: string, </br> timestamp: string, </br> measures: Array&lt;any&gt;) => void**|`null`|`null` |Handler for when an element in a non-numeric plot in the line chart is clicked. the parameters are: data group, series name, timestamp, and measures at that timestamp|
|`rollupCategoricalValues`|**boolean**| `true`, `false` | `false`|For categorical plots in line charts, this specifies that adjacent measures with the same values should be rolled into the first with those values|
|`eventElementType`|**string**|`'diamond'`, `'teardrop'`| `'diamond'` | Specifies the svg icon for an event in the line chart, either a `diamond` or a `teardrop`|
|`connectPoints`|**boolean**|`true`, `false` | `false` | For scatter plots. Specifies connected scatter plot mode.
|`pointConnectionMeasure`| **string**| `measure` | `''` | For scatter plots. Specifies measure by which to connect points, defaults to time if no measure given.
***Note**: For boolean values, the property will evaluate to `true` if either value is `true`. For other types of values, the chart data option value will take precedence over the chart option value.*
Expand Down
93 changes: 93 additions & 0 deletions pages/examples/testcases/connectedScatterPlot.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@

<!DOCTYPE html>
<html>
<head>
<title>Scatter Plot</title>

<!-- boilerplate headers are injected with head.js, grab them from the live example header, or include a link to head.js -->
<script src="../boilerplate/head.js"></script>
</head>
<body style="font-family: 'Segoe UI', sans-serif;">
<div id="chart1" style=" height: 800px;"></div>
<div style="display: flex; flex-direction: row; justify-content: center; margin-top: 50px;">
<div id="temporalToggle" class = "buttonWrapper" style= "margin-right: 50px; display: none;"><button onclick="toggleTemporal()">Toggle Temporal</button></div>
<div id="newDataSetToggle" class = "buttonWrapper" style= "margin-right: 50px; display: none;"><button onclick="newDataSetTrigger()">New Data Set</button></div>
<div id="randomizeData" class = "buttonWrapper" style= "display: none;"><button onclick="ranomizeData()">Randomize data</button></div>
</div>
<script>
let temporal = false;
let toggleTemporal = null;
let newDataSetTrigger = null;
let ranomizeData = null;
let dataSize = [3,1];

window.onload = function() {
// Show toggle & data trigger
document.getElementById("temporalToggle").style.display = "flex";
document.getElementById("newDataSetToggle").style.display = "flex";
document.getElementById("randomizeData").style.display = "flex";

// create fake data in the shape our charts expect (connected in series around circle)
let getData = (scaleFactor = 10, offset = 0) => {
let data = [];
let from = new Date();
let to;

for(let i = 0; i < dataSize[0]; i++){
let lines = {};
data.push({[`Drill_Site${i}`]: lines});
for(let j = 0; j < dataSize[1]; j++){
let values = {};
lines[`Drill${j}`] = values;
for(let k = 0; k < 8 * Math.PI; k+=.5){
let to = new Date(from.valueOf() + 1000*k);
let y = scaleFactor * Math.sin(k) + offset + (scaleFactor / 5) + Math.random();
let x = scaleFactor * (i === 1 ? .6 : i === 2 ? .3 : 1) * Math.cos(k) + offset + Math.random();
let temp = Math.random() * scaleFactor;
values[to.toISOString()] = {x, y, temp};
}
}
}
return data;
}

// render the data in a chart
let tsiClient = new TsiClient();

let scatterPlot = new tsiClient.ux.ScatterPlot(document.getElementById('chart1'));

let renderScatterPlot = (data=getData()) => scatterPlot.render(data, {
legend: 'shown',
spAxisLabels: ['X (mm)', 'Y (mm)'],
noAnimate: false,
isTemporal: temporal,
grid: true,
tooltip: true,
theme: 'light',
spMeasures: ['x', 'y', 'temp'],
interpolationFunction: "curveLinear"
},
[
{connectPoints: true},
{connectPoints: true, pointConnectionMeasure: "x"},
{connectPoints: true, pointConnectionMeasure: "y"}
]
);

renderScatterPlot();

newDataSetTrigger = () => {
dataSize = [Math.ceil(Math.random() * 3),Math.ceil(Math.random() * 3)]
renderScatterPlot(getData(10, 0));
}
ranomizeData = () => renderScatterPlot();

toggleTemporal = () => {
temporal = !temporal;
renderScatterPlot();
}
};

</script>
</body>
</html>
3 changes: 0 additions & 3 deletions src/UXClient/Components/ScatterPlot/ScatterPlot.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@
fill: $gray1;
}

path {
stroke: $gray1;
}
.tsi-focusLine {
stroke: $gray2;
}
Expand Down
117 changes: 114 additions & 3 deletions src/UXClient/Components/ScatterPlot/ScatterPlot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as d3 from 'd3';
import './ScatterPlot.scss';
import { ChartVisualizationComponent } from './../../Interfaces/ChartVisualizationComponent';
import { ChartDataOptions } from '../../Models/ChartDataOptions';
import { Legend } from './../Legend/Legend';
import { ScatterPlotData } from '../../Models/ScatterPlotData';
import Slider from './../Slider/Slider';
Expand All @@ -20,6 +19,7 @@ class ScatterPlot extends ChartVisualizationComponent {
private height: number;
private measures: Array<string>;
private pointWrapper: any;
private lineWrapper: any;
private rMeasure: string;
private rScale: any;
private slider: any;
Expand All @@ -46,7 +46,6 @@ class ScatterPlot extends ChartVisualizationComponent {

chartComponentData = new ScatterPlotData();


constructor(renderTarget: Element){
super(renderTarget);
this.chartMargins = {
Expand Down Expand Up @@ -98,6 +97,9 @@ class ScatterPlot extends ChartVisualizationComponent {
this.g = this.svgSelection.append("g")
.classed("tsi-svgGroup", true)

this.lineWrapper = this.g.append("g")
.classed("tsi-lineWrapper", true);

this.pointWrapper = this.g.append("g")
.classed("tsi-pointWrapper", true);

Expand Down Expand Up @@ -191,6 +193,7 @@ class ScatterPlot extends ChartVisualizationComponent {

this.legendPostRenderProcess(this.chartOptions.legend, this.svgSelection, false);
}

private getSliderWidth () {
return this.chartWidth + this.chartMargins.left + this.chartMargins.right - 16;
}
Expand Down Expand Up @@ -308,6 +311,9 @@ class ScatterPlot extends ChartVisualizationComponent {
// Draw axis labels
this.drawAxisLabels();

// Draw connecting lines (if toggled on)
this.drawConnectingLines();

// Draw data
let scatter = this.pointWrapper.selectAll(".tsi-dot")
.data(this.cleanData(this.chartComponentData.temporalDataArray), (d) => {
Expand Down Expand Up @@ -374,6 +380,90 @@ class ScatterPlot extends ChartVisualizationComponent {
.style("width", `${this.svgSelection.node().getBoundingClientRect().width + 10}px`);
}

/******** DRAW CONNECTING LINES BETWEEN POINTS ********/
private drawConnectingLines(){
// Don't render connecting lines on temporal mode
if(this.chartOptions.isTemporal){
this.lineWrapper.selectAll("*").remove();
return;
}

let dataSet = this.cleanData(this.chartComponentData.temporalDataArray);
let connectedSeriesMap = {};

// Find measure by which to connect series of points
const getPointConnectionMeasure = (point => {
let pConMes = this.aggregateExpressionOptions[point.aggregateKeyI]?.pointConnectionMeasure;
return pConMes && pConMes in point.measures ? pConMes : null;
})

// Map data into groups of connected points, if connectedPoints enabled for agg
dataSet.forEach(point => {
if(point.aggregateKeyI !== null && point.aggregateKeyI < this.aggregateExpressionOptions.length &&
this.aggregateExpressionOptions[point.aggregateKeyI].connectPoints){
let series = point.aggregateKey + "_" + point.splitBy;
if(series in connectedSeriesMap){
connectedSeriesMap[series].data.push(point);
} else{
connectedSeriesMap[series] = {
data: [point],
pointConnectionMeasure: getPointConnectionMeasure(point)
}
}
}
})

// Sort connected series by pointConnectionMeasure
for(let key of Object.keys(connectedSeriesMap)){
let sortMeasure = connectedSeriesMap[key].pointConnectionMeasure;
// If sort measure specified, sort by that measure
if(sortMeasure){
connectedSeriesMap[key].data.sort((a,b) => {
if(a.measures[sortMeasure] < b.measures[sortMeasure]) return -1;
if(a.measures[sortMeasure] > b.measures[sortMeasure]) return 1;
return 0;
})
}
}

let line = d3.line()
.x((d:any) => this.xScale(d.measures[this.xMeasure]))
.y((d:any) => this.yScale(d.measures[this.yMeasure]))
.curve(this.chartOptions.interpolationFunction); // apply smoothing to the line

// Group lines by aggregate
let connectedGroups = this.lineWrapper.selectAll(`.tsi-lineSeries`).data(Object.keys(connectedSeriesMap));

let self = this;

connectedGroups.enter()
.append("g")
.attr("class", 'tsi-lineSeries')
.merge(connectedGroups)
.each(function(seriesName){
let series = d3.select(this).selectAll(`.tsi-line`).data([connectedSeriesMap[seriesName].data], d => d[0].aggregateKeyI+d[0].splitBy);

series.exit().remove();

series
.enter()
.append("path")
.attr("class", `tsi-line`)
.merge(series)
.attr("fill", "none")
.transition()
.duration(self.chartOptions.noAnimate ? 0 : self.TRANSDURATION)
.ease(d3.easeExp)
.attr("stroke", (d) => Utils.colorSplitBy(self.chartComponentData.displayState, d[0].splitByI, d[0].aggregateKey, self.chartOptions.keepSplitByColor))
.attr("stroke-width", 2.5)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("d", line)
})

connectedGroups.exit().remove()
}

/******** CHECK VALIDITY OF EXTENTS ********/
private checkExtentValidity(){
if(this.chartComponentData.allValues == 0){
Expand Down Expand Up @@ -515,7 +605,7 @@ class ScatterPlot extends ChartVisualizationComponent {
if (aggKey !== this.focusedAggKey || splitBy !== this.focusedSplitBy) {
let selectedFilter = Utils.createValueFilter(aggKey, splitBy);
let oldFilter = Utils.createValueFilter(this.focusedAggKey, this.focusedSplitBy);

this.svgSelection.selectAll(".tsi-dot")
.filter(selectedFilter)
.attr("stroke-opacity", this.standardStroke)
Expand All @@ -526,6 +616,18 @@ class ScatterPlot extends ChartVisualizationComponent {
.attr("stroke-opacity", this.lowStroke)
.attr("fill-opacity", this.lowOpacity)

let lineSelectedFilter = (d: any) => {
return (d[0].aggregateKey === aggKey && d[0].splitBy === splitBy)
}

this.svgSelection.selectAll(".tsi-line")
.filter((d: any) => lineSelectedFilter(d))
.attr("stroke-opacity", this.standardStroke)

this.svgSelection.selectAll(".tsi-line")
.filter((d: any) => !lineSelectedFilter(d))
.attr("stroke-opacity", this.lowStroke)

this.focusedAggKey = aggKey;
this.focusedSplitBy = splitBy;
}
Expand Down Expand Up @@ -651,6 +753,12 @@ class ScatterPlot extends ChartVisualizationComponent {
.filter(selectedFilter)
.attr("stroke-opacity", this.lowStroke)
.attr("fill-opacity", this.lowOpacity)

// Decrease opacity of unselected line
this.svgSelection.selectAll(".tsi-line")
.filter((d: any) => !(d[0].aggregateKey === aggKey && d[0].splitBy === splitBy))
.attr("stroke-opacity", this.lowStroke)

}

/******** UNHIGHLIGHT FOCUSED GROUP ********/
Expand All @@ -664,6 +772,9 @@ class ScatterPlot extends ChartVisualizationComponent {
.attr("stroke", (d) => Utils.colorSplitBy(this.chartComponentData.displayState, d.splitByI, d.aggregateKey, this.chartOptions.keepSplitByColor))
.attr("fill", (d) => Utils.colorSplitBy(this.chartComponentData.displayState, d.splitByI, d.aggregateKey, this.chartOptions.keepSplitByColor))
.attr("stroke-width", "1px");

this.g.selectAll(".tsi-line")
.attr("stroke-opacity", this.standardStroke)
}

/******** FILTER DATA, ONLY KEEPING POINTS WITH ALL REQUIRED MEASURES ********/
Expand Down
4 changes: 4 additions & 0 deletions src/UXClient/Models/ChartDataOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class ChartDataOptions {
public positionY: number;
public swimLane: number;
public variableAlias: any;
public connectPoints: boolean = false;
public pointConnectionMeasure: string = '';
public positionXVariableName: string;
public positionYVariableName: string;
public image: string;
Expand Down Expand Up @@ -56,6 +58,8 @@ class ChartDataOptions {
this.positionY = Utils.getValueOrDefault(optionsObject, 'positionY', 0);
this.swimLane = Utils.getValueOrDefault(optionsObject, 'swimLane', null);
this.variableAlias = Utils.getValueOrDefault(optionsObject, 'variableAlias', null);
this.connectPoints = Utils.getValueOrDefault(optionsObject, "connectPoints", false)
this.pointConnectionMeasure = Utils.getValueOrDefault(optionsObject, 'pointConnectionMeasure', '')
this.positionXVariableName = Utils.getValueOrDefault(optionsObject, 'positionXVariableName', null);
this.positionYVariableName = Utils.getValueOrDefault(optionsObject, 'positionYVariableName', null);
this.image = Utils.getValueOrDefault(optionsObject, 'image', null);
Expand Down
1 change: 1 addition & 0 deletions src/UXClient/Models/ScatterPlotData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class ScatterPlotData extends GroupedBarChartData {

this.temporalDataArray.push({
aggregateKey: aggKey,
aggregateKeyI: this.data.findIndex((datum) => datum.aggKey === aggKey),
splitBy: splitBy,
measures,
timestamp,
Expand Down

0 comments on commit 9198d4a

Please sign in to comment.