Skip to content

Instantly share code, notes, and snippets.

@tlazaro
Last active August 16, 2020 20:22
Show Gist options
  • Save tlazaro/18771e950812ff8ad49a4e90e7feca1c to your computer and use it in GitHub Desktop.
Save tlazaro/18771e950812ff8ad49a4e90e7feca1c to your computer and use it in GitHub Desktop.
Scatter Plot with marginal Histograms
import React from "react";
import { ChartingProvider } from "@chart-parts/react";
import { Renderer } from "@chart-parts/react-svg-renderer";
import "./App.css";
import { ScatterPlotWithHistogram } from "./ScatterPlotWithHistogram/ScatterPlotWithHistogram";
const renderer = new Renderer();
const exData = [
{ key: 0, value: 28, c: 0 },
{ key: 0, value: 20, c: 1 },
{ key: 1, value: 43, c: 0 },
{ key: 1, value: 35, c: 1 },
{ key: 2, value: 81, c: 0 },
{ key: 2, value: 10, c: 1 },
{ key: 3, value: 19, c: 0 },
{ key: 3, value: 15, c: 1 },
{ key: 4, value: 52, c: 0 },
{ key: 4, value: 48, c: 1 },
{ key: 5, value: 24, c: 0 },
{ key: 5, value: 28, c: 1 },
{ key: 6, value: 87, c: 0 },
{ key: 6, value: 66, c: 1 },
{ key: 7, value: 17, c: 0 },
{ key: 7, value: 27, c: 1 },
{ key: 8, value: 68, c: 0 },
{ key: 8, value: 16, c: 1 },
{ key: 9, value: 49, c: 0 },
{ key: 9, value: 25, c: 1 },
];
function App() {
return (
<ChartingProvider value={renderer}>
<ScatterPlotWithHistogram
height={600}
width={600}
data={exData}
title={"Scatter Plot"}
description={"My very own Scatter Plot"}
groupBy={"c"}
xOffset={0}
yOffset={0}
/>
</ChartingProvider>
);
}
export default App;
/*!
* Copyright (c) Microsoft. All rights reserved.
* Licensed under the MIT license. See LICENSE file in the project.
*/
import React, { memo } from "react";
import { Circle } from "@chart-parts/react";
import { FillMarkProps } from "../types";
import { MarkEncoding } from "@chart-parts/interfaces";
import {
encodeCategoryAriaTitle,
encodeCategoryAriaDescription,
} from "../hooks/encodings";
const DEFAULT_STROKE = "black";
const DEFAULT_FILL: MarkEncoding<string> = (ctx) => ctx.color(ctx.d._category);
const DEFAULT_FILL_OPACITY = 1;
const DEFAULT_STROKE_WIDTH = 2;
const DEFAULT_RADIUS = 50;
export interface CircleMarksProps extends FillMarkProps {
radius?: MarkEncoding<number>;
table?: string;
xOffset: number;
yOffset: number;
}
export const CircleMarks: React.FC<CircleMarksProps> = memo(
function CircleMarks({
onMouseEnter,
onMouseLeave,
onClick,
fill = DEFAULT_FILL,
fillOpacity = DEFAULT_FILL_OPACITY,
stroke = DEFAULT_STROKE,
strokeWidth = DEFAULT_STROKE_WIDTH,
radius = DEFAULT_RADIUS,
table = "data",
xOffset = 0,
yOffset = 0,
}) {
return (
<Circle
table={table}
tabIndex={-1}
zIndex={0}
size={radius}
fill={fill}
stroke={stroke}
fillOpacity={fillOpacity}
strokeWidth={strokeWidth}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
ariaTitle={encodeCategoryAriaTitle}
ariaDescription={encodeCategoryAriaDescription}
x={({ d, x }) => x(d.key) - xOffset}
y={({ d, y }) => y(d.value) - yOffset}
/>
);
}
);
/*!
* Copyright (c) Microsoft. All rights reserved.
* Licensed under the MIT license. See LICENSE file in the project.
*/
import { MarkEncoding, Facet } from '@chart-parts/interfaces'
import { useMemo } from 'react'
export const encodeCategoryAriaTitle: MarkEncoding<string> = ({ d }): any =>
`Category ${d.key}`
export const encodeCategoryAriaDescription: MarkEncoding<string> = ({
d,
}: any) => `Category ${d.key} value is ${d.value}`
export function useGroupByFaceting(groupBy: string | undefined): Facet {
return useMemo(() => ({ name: 'facet', groupBy }), [groupBy])
}
/*!
* Copyright (c) Microsoft. All rights reserved.
* Licensed under the MIT license. See LICENSE file in the project.
*/
import { useMemo } from 'react'
import { CategoryData, ProcessedCategoryData } from '../types'
export function useDataGroupSorted(
groupBy: string | undefined,
data: CategoryData[],
): ProcessedCategoryData[] {
return useMemo(() => {
// calculate position for each category based on groupBy prop
const categorized = data.reduce((acc: any, datapoint: CategoryData) => {
const value = groupBy ? datapoint[groupBy] : 'default'
const d = { ...datapoint, _category: value }
if (acc[value]) {
acc[value].push(d)
} else {
acc[value] = [d]
}
return acc
}, {})
const posCatagories = Object.values(categorized).reduce(
(acc: any, d: any) => {
const sortByKey = d.sort((a: any, b: any) => a.key - b.key)
sortByKey.forEach((item: any) => acc.push(item))
return acc
},
[],
) as any
return posCatagories
}, [data, groupBy]) as ProcessedCategoryData[]
}
import React, { memo, useMemo } from "react";
import {
LinearScale,
Axis,
OrdinalScale,
CategoricalColorScheme,
Group,
Chart,
Rect,
} from "@chart-parts/react";
import {
bin,
dataset,
aggregate,
AggregateOperation,
DatasetTransform,
} from "@chart-parts/transform";
import { LineChartProps } from "../LineChart/LineChart";
import { useDataGroupSorted } from "../hooks/useDataGroupSorted";
import { AxisOrientation } from "@chart-parts/interfaces";
import { CircleMarks, CircleMarksProps } from "./CircleMarks";
const BottomAxisHeight = 29;
const SideAxisWidth = 23;
interface ScatterPlotRow {
key: any;
value: any;
c: any;
}
export interface ScatterPlotWithHistogramProps
extends LineChartProps,
CircleMarksProps {
data: ScatterPlotRow[];
}
interface ScatterPlotProps {
table: string;
width: number;
height: number;
xOffset: number;
yOffset: number;
}
const ScatterPlot: React.FC<ScatterPlotProps> = memo(function ScatterPlot({
table,
width,
height,
xOffset,
yOffset,
}) {
return (
<Group
x={xOffset}
x2={xOffset + width}
y={yOffset}
y2={yOffset + height}
height={height}
width={width}
>
<OrdinalScale
name="color"
domain="categorized._category"
colorScheme={CategoricalColorScheme.category10}
/>
<LinearScale
name="x"
nice={true}
domain="categorized.key"
range={[SideAxisWidth, width]}
padding={0.5}
zero
/>
<LinearScale
name="y"
nice={true}
domain="categorized.value"
range={[height - BottomAxisHeight, BottomAxisHeight]}
zero
/>
<Axis orient={AxisOrientation.Bottom} scale="x" labelPadding={10} />
<Axis orient={AxisOrientation.Left} scale="y" />
<CircleMarks table={table} xOffset={SideAxisWidth} yOffset={0} />
</Group>
);
});
interface BarChartProps {
table: string;
barThickness: number;
width: number;
height: number;
xOffset: number;
yOffset: number;
}
const BarChartTop: React.FC<BarChartProps> = memo(function BarChartTop({
table,
barThickness,
width,
height,
xOffset,
yOffset,
}) {
return (
<Group
x={xOffset}
x2={xOffset + width}
y={yOffset}
y2={yOffset + height}
height={height}
width={width}
>
<LinearScale
name="y"
domain="histogram-top.count"
range={[height - BottomAxisHeight, BottomAxisHeight]}
zero
/>
<LinearScale
name="x"
domain="histogram-top.bin0"
range={[SideAxisWidth, width]}
padding={0}
zero
nice={false}
/>
<Axis orient={AxisOrientation.Left} scale="y" />
<Axis
orient={AxisOrientation.Bottom}
scale="x"
labelPadding={10}
bandPosition={0.0}
/>
<Rect
table={table}
width={({ d, x }) => x(barThickness / 100)}
x={({ d, x }) => x(d.bin0) - SideAxisWidth}
y={({ d, y }) => y(d.count)}
y2={({ y }) => y(0)}
fill="steelblue"
/>
</Group>
);
});
const BarChartSide: React.FC<BarChartProps> = memo(function BarChartSide({
table,
barThickness,
width,
height,
xOffset,
yOffset,
}) {
return (
<Group
x={xOffset}
x2={xOffset + width}
y={yOffset}
y2={yOffset + height}
height={height}
width={width}
>
<LinearScale
name="y"
domain="histogram-side.bin1"
range={[height - BottomAxisHeight, BottomAxisHeight]}
zero
/>
<LinearScale
name="x"
domain="histogram-side.count"
range={[SideAxisWidth, width]}
padding={0}
zero
nice
/>
<Axis orient={AxisOrientation.Left} scale="y" />
<Axis
orient={AxisOrientation.Bottom}
scale="x"
labelPadding={10}
bandPosition={0.0}
tickCount={5}
/>
<Rect
table={table}
height={({ d, x }) => x(barThickness / 100)}
x={({ d, x }) => x(0) - SideAxisWidth}
x2={({ d, x }) => x(d.count) - SideAxisWidth}
y={({ d, y }) => y(d.bin1)}
fill="steelblue"
/>
</Group>
);
});
function binTransform(
field: string,
min: number,
max: number,
offset: number,
step: number
): [DatasetTransform[], number] {
return [
[
bin(field).extent(min, max).nice(true).anchor(offset).step(step),
aggregate()
.groupBy("bin0", "bin1")
.compute({
field: "bin0",
op: AggregateOperation.count,
as: "count",
})
.drop(false),
],
(max - min) / step,
];
}
export const ScatterPlotWithHistogram: React.FC<ScatterPlotWithHistogramProps> = memo(
function ScatterPlotWithHistogram({
data,
height,
width,
title,
chartPadding,
description,
children,
groupBy,
xAxisProps,
yAxisProps,
...props
}) {
const [sideBinTransform, yHeight] = binTransform("value", 0, 100, 0, 10);
const [topBinTransform, xWidth] = binTransform("key", 0, 10, 0, 1);
const categorizedData = useChartData(data, groupBy).data;
const tables = useMemo(
() =>
dataset()
.addTable("all", data)
.addTable("categorized", categorizedData)
.addDerivedTable("histogram-side", "all", ...sideBinTransform)
.addDerivedTable("histogram-top", "all", ...topBinTransform).tables,
[]
);
const topHistogramWeight = 1;
const sideHistogramWeight = 1;
const scatterPlotWeight = 3;
const scatterPlotHeight =
height * (scatterPlotWeight / (scatterPlotWeight + topHistogramWeight));
const scatterPlotWidth =
width * (scatterPlotWeight / (scatterPlotWeight + sideHistogramWeight));
const topHistogramWidth = scatterPlotWidth;
const topHistogramHeight =
height * (topHistogramWeight / (scatterPlotWeight + topHistogramWeight));
const sideHistogramWidth =
width * (sideHistogramWeight / (scatterPlotWeight + sideHistogramWeight));
const sideHistogramHeight = scatterPlotHeight;
return (
<Chart
width={width}
height={height}
data={tables}
title={title}
description={description}
padding={chartPadding}
>
<ScatterPlot
table="categorized"
width={scatterPlotWidth}
height={scatterPlotHeight}
xOffset={0}
yOffset={height - scatterPlotHeight}
/>
<BarChartTop
table="histogram-top"
width={topHistogramWidth}
height={topHistogramHeight}
barThickness={xWidth}
xOffset={0}
yOffset={0}
/>
<BarChartSide
table="histogram-side"
width={sideHistogramWidth}
height={sideHistogramHeight}
barThickness={yHeight}
xOffset={scatterPlotWidth}
yOffset={topHistogramHeight}
/>
</Chart>
);
}
);
function useChartData(data: any[], groupBy: string) {
const sortedData = useDataGroupSorted(groupBy, data);
return useMemo(() => ({ data: sortedData }), [sortedData]);
}
@tlazaro
Copy link
Author

tlazaro commented Aug 16, 2020

The code is super bad, could have more reuse. First time using chart-parts, understanding the composition plus trying to figure out how to make a histogram there plus trying to do the layout.

The purpose of this gist is sharing to get to understanding how to better compose different charts. I looked into this example for some inspiration PopulationPyramid.

@tlazaro
Copy link
Author

tlazaro commented Aug 16, 2020

image

@tlazaro
Copy link
Author

tlazaro commented Aug 16, 2020

Regarding histograms, using Vega there is a 'binned domain' that can already render data without too much effort: https://vega.github.io/vega/examples/histogram/. Something along those lines could also be useful.

@tlazaro
Copy link
Author

tlazaro commented Aug 16, 2020

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment