Skip to content

Commit 4a29c4e

Browse files
committed
X/Y scatter chart multi-chart
1 parent 2ffef92 commit 4a29c4e

File tree

12 files changed

+429
-192
lines changed

12 files changed

+429
-192
lines changed

packages/perspective-viewer-d3fc/src/js/charts/xy-scatter.js

Lines changed: 73 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
1212

1313
import * as fc from "d3fc";
14-
import * as d3 from "d3";
14+
import { select } from "d3";
1515
import { axisFactory } from "../axis/axisFactory";
1616
import { chartCanvasFactory } from "../axis/chartFactory";
1717
import {
@@ -20,8 +20,7 @@ import {
2020
} from "../series/pointSeriesCanvas";
2121
import { pointData } from "../data/pointData";
2222
import {
23-
seriesColorsFromField,
24-
seriesColorsFromGroups,
23+
seriesColorsFromColumn,
2524
seriesColorsFromDistinct,
2625
colorScale,
2726
} from "../series/seriesColors";
@@ -34,24 +33,8 @@ import { hardLimitZeroPadding } from "../d3fc/padding/hardLimitZero";
3433
import zoomableChart from "../zoom/zoomableChart";
3534
import nearbyTip from "../tooltip/nearbyTip";
3635
import { symbolsObj } from "../series/seriesSymbols";
37-
38-
/**
39-
* Define a clamped scaling factor based on the container size for bubble plots.
40-
*
41-
* @param {Array} p1 a point as a tuple of `Number`
42-
* @param {Array} p2 a second point as a tuple of `Number`
43-
* @returns a function `container -> integer` which calculates a scaling factor
44-
* from the linear function (clamped) defgined by the input points
45-
*/
46-
function interpolate_scale([x1, y1], [x2, y2]) {
47-
const m = (y2 - y1) / (x2 - x1);
48-
const b = y2 - m * x2;
49-
return function (container) {
50-
const node = container.node();
51-
const shortest_axis = Math.min(node.clientWidth, node.clientHeight);
52-
return Math.min(y2, Math.max(y1, m * shortest_axis + b));
53-
};
54-
}
36+
import { gridLayoutMultiChart } from "../layout/gridLayoutMultiChart";
37+
import xyScatterSeries from "../series/xy-scatter/xyScatterSeries";
5538

5639
/**
5740
* Overrides specific symbols based on plugin settings. This modifies in-place _and_ returns the value.
@@ -93,122 +76,95 @@ function overrideSymbols(settings, symbols) {
9376
return symbols;
9477
}
9578

96-
/**
97-
* @param {d3.Selection} container - d3.Selection of the outer div
98-
* @param {any} settings - settings as defined in the Update method in plugin.js
99-
*/
10079
function xyScatter(container, settings) {
80+
const colorBy = settings.realValues[2];
81+
let hasColorBy = !!colorBy;
82+
let isColoredByString =
83+
settings.mainValues.find((x) => x.name === colorBy)?.type === "string";
84+
85+
let color = null;
86+
let legend = null;
87+
10188
const symbolCol = settings.realValues[4];
102-
// TODO: This is failing to filter correctly when colorLegend() is called as it returns data meant for filterData
103-
const data = pointData(settings, filterDataByGroup(settings));
10489
const symbols = overrideSymbols(
10590
settings,
10691
symbolTypeFromColumn(settings, symbolCol)
10792
);
10893

109-
let color = null;
110-
let legend = null;
111-
112-
const colorByField = 2;
113-
const colorByValue = settings.realValues[colorByField];
114-
let hasColorBy = colorByValue !== null && colorByValue !== undefined;
115-
let isColoredByString =
116-
settings.mainValues.find((x) => x.name === colorByValue)?.type ===
117-
"string";
118-
let hasSymbol = !!symbolCol;
94+
const data = pointData(settings, filterDataByGroup(settings));
11995

120-
if (hasColorBy) {
121-
if (isColoredByString) {
122-
if (hasSymbol) {
96+
if (hasColorBy && isColoredByString) {
97+
if (!!symbolCol) {
98+
// TODO: Legend should have cartesian product labels (ColorBy|SplitBy)
99+
// For now, just use monocolor legends.
100+
if (settings.splitValues.length > 0) {
123101
color = seriesColorsFromDistinct(settings, data);
124-
// TODO: Legend should have cartesian product labels (ColorBy|Symbol)
125-
// For now, just use monocolor legends.
126-
legend = symbolLegend().settings(settings).scale(symbols);
127102
} else {
128-
color = seriesColorsFromField(settings, colorByField);
129-
legend = colorLegend().settings(settings).scale(color);
103+
color = seriesColorsFromDistinct(settings, data[0]);
130104
}
105+
legend = symbolLegend().settings(settings).scale(symbols);
131106
} else {
132-
color = seriesColorRange(settings, data, "colorValue");
133-
legend = colorRangeLegend().scale(color);
107+
color = seriesColorsFromColumn(settings, colorBy);
108+
legend = colorLegend().settings(settings).scale(color);
134109
}
110+
} else if (hasColorBy) {
111+
color = seriesColorRange(settings, data, "colorValue");
112+
legend = colorRangeLegend().scale(color);
135113
} else {
136114
// always use default color
137115
color = colorScale().settings(settings).domain([""])();
138116
legend = symbolLegend().settings(settings).scale(symbols);
139117
}
140118

141-
const size = settings.realValues[3]
142-
? seriesLinearRange(settings, data, "size").range([10, 10000])
143-
: null;
144-
145-
const label = settings.realValues[5];
146-
147-
const scale_factor = interpolate_scale([600, 0.1], [1600, 1])(container);
148-
const series = fc
149-
.seriesCanvasMulti()
150-
.mapping((data, index) => data[index])
151-
.series(
152-
data.map((series) =>
153-
pointSeriesCanvas(
154-
settings,
155-
symbolCol,
156-
size,
157-
color,
158-
label,
159-
symbols,
160-
scale_factor
161-
)
119+
if (settings.splitValues.length !== 0) {
120+
let xyGrid = gridLayoutMultiChart()
121+
.svg(false)
122+
.elementsPrefix("xy-scatter");
123+
xyGrid = xyGrid.padding("2.5em");
124+
125+
const xLabel = container.append("div").attr("class", "multi-xlabel");
126+
xLabel.append("p").text(settings.mainValues[0].name);
127+
128+
const yLabel = container.append("div").attr("class", "multi-ylabel");
129+
yLabel
130+
.append("p")
131+
.text(settings.mainValues[1].name)
132+
.style("transform", "rotate(-90deg)")
133+
.style("text-wrap", "nowrap");
134+
135+
container = container.datum(data);
136+
container.call(xyGrid);
137+
const xyContainer = xyGrid.chartContainer();
138+
const xyEnter = xyGrid.chartEnter();
139+
const xyDiv = xyGrid.chartDiv();
140+
const xyTitle = xyGrid.chartTitle();
141+
const containerSize = xyGrid.containerSize();
142+
143+
xyTitle.each((d, i, nodes) => select(nodes[i]).text(d.key));
144+
xyEnter
145+
.merge(xyDiv)
146+
.attr(
147+
"transform",
148+
`translate(${containerSize.width / 2}, ${
149+
containerSize.height / 2
150+
})`
162151
)
163-
);
164-
165-
const axisDefault = () =>
166-
axisFactory(settings)
167-
.settingName("mainValues")
168-
.paddingStrategy(hardLimitZeroPadding())
169-
.pad([0.1, 0.1]);
170-
171-
const xAxis = axisDefault()
172-
.settingValue(settings.mainValues[0].name)
173-
.memoValue(settings.axisMemo[0])
174-
.valueName("x")(data);
175-
176-
const yAxis = axisDefault()
177-
.orient("vertical")
178-
.settingValue(settings.mainValues[1].name)
179-
.memoValue(settings.axisMemo[1])
180-
.valueName("y")(data);
181-
182-
const chart = chartCanvasFactory(xAxis, yAxis)
183-
.xLabel(settings.mainValues[0].name)
184-
.yLabel(settings.mainValues[1].name)
185-
.plotArea(withGridLines(series, settings).canvas(true));
186-
187-
chart.xNice && chart.xNice();
188-
chart.yNice && chart.yNice();
189-
190-
const zoomChart = zoomableChart()
191-
.chart(chart)
192-
.settings(settings)
193-
.xScale(xAxis.scale)
194-
.yScale(yAxis.scale)
195-
.canvas(true);
196-
197-
const toolTip = nearbyTip()
198-
.scaleFactor(scale_factor)
199-
.settings(settings)
200-
.canvas(true)
201-
.xScale(xAxis.scale)
202-
.xValueName("x")
203-
.yValueName("y")
204-
.yScale(yAxis.scale)
205-
.color(!hasColorBy && color)
206-
.size(size)
207-
.data(data);
208-
209-
// render
210-
container.datum(data).call(zoomChart);
211-
container.call(toolTip);
152+
.each(function (data) {
153+
const xyElement = select(this);
154+
xyScatterSeries()
155+
.settings(settings)
156+
.data([data])
157+
.color(color)
158+
.symbols(symbols)(xyElement);
159+
});
160+
} else {
161+
xyScatterSeries()
162+
.settings(settings)
163+
.data(data)
164+
.color(color)
165+
.symbols(symbols)(container);
166+
}
167+
212168
if (legend) container.call(legend);
213169
}
214170

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2+
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3+
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4+
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5+
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6+
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7+
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8+
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9+
// ┃ This file is part of the Perspective library, distributed under the terms ┃
10+
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11+
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12+
13+
/**
14+
* This function filters settings.data to get values corresponding to an input column.
15+
* It accomodates for split-by.
16+
* @param {*} settings
17+
* @param {string} column
18+
*/
19+
export function getValuesByColumn(settings, column) {
20+
return settings.data
21+
.map((row) =>
22+
Object.entries(row)
23+
.filter(([k, v]) => k.split("|").at(-1) === column && !!v)
24+
.map(([k, v]) => v)
25+
)
26+
.flat();
27+
}

packages/perspective-viewer-d3fc/src/js/layout/gridLayoutMultiChart.js

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,29 @@ export function gridLayoutMultiChart() {
2121
let chartTitle = null;
2222
let color = null;
2323
let containerSize = null;
24+
let svg = true;
25+
let padding = null;
2426

2527
const _gridLayoutMultiChart = (container) => {
26-
const innerContainer = getOrCreateElement(
28+
const outerContainer = getOrCreateElement(
2729
container,
30+
"div.outer-container",
31+
() =>
32+
container
33+
.append("div")
34+
.attr("class", "outer-container")
35+
.style("width", `calc(100% - ${padding ?? 0})`)
36+
.style("height", `calc(100% - ${padding ?? 0})`)
37+
.style("padding-left", padding ?? 0)
38+
);
39+
40+
const scrollContainer = getOrCreateElement(
41+
outerContainer,
2842
"div.inner-container",
29-
() => container.append("div").attr("class", "inner-container")
43+
() => outerContainer.append("div").attr("class", "inner-container")
3044
);
3145

32-
const innerRect = innerContainer.node().getBoundingClientRect();
46+
const innerRect = outerContainer.node().getBoundingClientRect();
3347
const containerHeight = innerRect.height;
3448
const containerWidth = innerRect.width - (color ? 70 : 0);
3549

@@ -58,20 +72,20 @@ export function gridLayoutMultiChart() {
5872
}
5973

6074
if (data.length > 1) {
61-
innerContainer.style(
75+
scrollContainer.style(
6276
"grid-template-columns",
6377
`repeat(${cols}, ${100 / cols}%)`
6478
);
65-
innerContainer.style(
79+
scrollContainer.style(
6680
"grid-template-rows",
6781
`repeat(${rows}, ${containerSize.height}px)`
6882
);
6983
} else {
70-
innerContainer.style("grid-template-columns", `repeat(1, 100%)`);
71-
innerContainer.style("grid-template-rows", `repeat(1, 100%)`);
84+
scrollContainer.style("grid-template-columns", `repeat(1, 100%)`);
85+
scrollContainer.style("grid-template-rows", `repeat(1, 100%)`);
7286
}
7387

74-
chartDiv = innerContainer
88+
chartDiv = scrollContainer
7589
.selectAll(`div.${elementsPrefix}-container`)
7690
.data(data, (d) => d.split);
7791
chartDiv.exit().remove();
@@ -90,10 +104,30 @@ export function gridLayoutMultiChart() {
90104
.attr("class", "title")
91105
.style("text-align", "left");
92106

93-
chartContainer = chartEnter
94-
.append("svg")
95-
.append("g")
96-
.attr("class", elementsPrefix);
107+
if (svg) {
108+
chartContainer = chartEnter
109+
.append("svg")
110+
.append("g")
111+
.attr("class", elementsPrefix);
112+
} else {
113+
chartContainer = chartEnter
114+
.append("div")
115+
.attr("class", elementsPrefix);
116+
}
117+
};
118+
_gridLayoutMultiChart.padding = (...args) => {
119+
if (!args.length) {
120+
return padding;
121+
}
122+
padding = args[0];
123+
return _gridLayoutMultiChart;
124+
};
125+
_gridLayoutMultiChart.svg = (...args) => {
126+
if (!args.length) {
127+
return svg;
128+
}
129+
svg = args[0];
130+
return _gridLayoutMultiChart;
97131
};
98132

99133
_gridLayoutMultiChart.elementsPrefix = (...args) => {

0 commit comments

Comments
 (0)