pnut

The new and improved pnut

Usage no npm install needed!

<script type="module">
  import pnut from 'https://cdn.skypack.dev/pnut';
</script>

README

pnut

Flexible chart building blocks for React. (Somewhere between d3 and a charting library)

Basics

To render a chart you need three parts:

  1. A Series for your data
  2. Some Scales
  3. Components to render
import {Chart, Line, SingleSeries, ContinuousScale, ColorScale, Axis, layout} from './src/index';

function SavingsOverTime() {
    const data = [
        {day: 1, savings: 0},
        {day: 2, savings: 10},
        {day: 3, savings: 20},
        {day: 4, savings: 15},
        {day: 5, savings: 200}
    ];

    // Define our series with day as the primary dimension
    const series = SingleSeries({data});

    // calculate chart width, height and padding
    const ll = layout({width: 400, height: 400, left: 32, bottom: 32});

    // Set up scales to define our x, y and color
    const x = ContinuousScale({series, key: 'day', range: ll.xRange});
    const y = ContinuousScale({series, key: 'savings', range: ll.yRange, zero: true});
    const color = ColorScale({series, key: 'savings', set: ['#ee4400']});


    // create a scales object for each of our renderable components
    const scales = {series, x, y, color};

    // render a chart with two axis and a line
    return <Chart {...ll}>
        <Axis scales={scales} position="left" />
        <Axis scales={scales} position="bottom" />
        <Line scales={scales} strokeWidth="2" />
    </Chart>;
}

Design Choices

Pnut chooses to require data that would match rows from an SQL query. If you have pivoted data you will need to flatten it.

// good
[
    {value: 10, type: 'apples'},
    {value: 20, type: 'oranges'},
]
// bad
[
    {apples: 10, oranges: 20}
]

API

Series

The first step in building a chart with pnut is to build a series object. The series defines how to group your data ready for rendering in an x/y plane. Under the hood it holds your data a two dimensional array of groups and points.

Grouped Series

Grouped series are used for things like multi line charts and stacked areas. One group per line and one point to match each x axis item.

const data = [
    {day: 1, type: 'apples', value: 0},
    {day: 2, type: 'apples', value: 10},
    {day: 3, type: 'apples', value: 20},
    {day: 4, type: 'apples', value: 15},
    {day: 5, type: 'apples', value: 200},
    {day: 1, type: 'oranges', value: 200},
    {day: 2, type: 'oranges', value: 50},
    {day: 3, type: 'oranges', value: 30},
    {day: 4, type: 'oranges', value: 24},
    {day: 5, type: 'oranges', value: 150}
];

const series = new GroupedSeries({groupKey: 'type', pointKey: 'day', data});

Single Series

A single series is just like group but there is only one group.


const data = [
    {day: 1, type: 'apples', value: 0},
    {day: 2, type: 'apples', value: 10},
    {day: 3, type: 'apples', value: 20},
    {day: 4, type: 'apples', value: 15},
    {day: 5, type: 'apples', value: 200}
];

const series = new SingleSeries({data});

Scales

Scales take your series and create functions that convert your data points to something that can be rendered. A classic example of this is converting your data points to a set of x/y coordinates. Each chart renderable will require specific set of scales in order to render. Each scale can be continuous, categorical or color.p For example:

  • A line chart needs a continuous x scale, a continuous y scale, and a color scale.
  • A column chart needs a categorical x scale, a continuous y scale, and a color scale.
  • A bubble chart needs a continuous scale for x,y and radius, and a color scale.

Continuous Scale

Continuous scales are for dimensions like numbers and dates, where the value is infinitely dividable.

type ContinuousScaleConfig = {
    series: Series, // A series object
    key: string, // Which key on your data points
    range: [number, number] // The min and max this scale should map to. (Often layout.yRange)
    zero?: boolean, // Force the scale to start at zero
    clamp?: boolean // Clamp values outside the series to min and max
};

// Examples
const y = ContinuousScale({series, key: 'value', range: layout.yRange, zero: true});
const x = ContinuousScale({series, key: 'date', range: layout.xRange});

Categorical Scale

Categorical scales are for dimensions where the values cannot be infinitely divided. Things like name, type, or favourite color. Dates can also be categorical but usually require some formatting to render properly.

type CategoricalScaleConfig = {
    series: Series, // A series object
    key: string, // Which key on your data points
    padding?: number, // How much space to place between categories (Only needs for column charts)
    range: [number, number] // The min and max this scale should map to. (Often layout.xRange)
};

// Examples
const x = ContinuousScale({series, key: 'favouriteColor', range: layout.xRange, padding: 0.1});

Color Scale

Color scales let you change the colors of your charts based on different attributes of your data.

There are four types:

  • Key - Use the color from a data point
  • Set - Assign a specific color palette to each distinct item in the data. This should pair with a CategoricalScale.
  • Range - Assign a range of colors and interpolate between them based on a continuous metric. This should pair with a ContinuousScale.
  • Interpolated - Take control of the colors by providing your own interpolator. interpolate is given a scaled value from 0 to 1.
// Get the color from `point.myColor`
const key = ColorScale({series, key: 'myColor'});


// Assign either red, green or blue based on `point.type`
const set = ColorScale({series, key: 'type', set: ['red', 'green', 'blue']});

// Blend age values from grey to red as they get older
const range = ColorScale({series, key: 'age', range: ['#ccc', 'red']});


// Apply custom interpolation to make the top half of values red
const interpolated = ColorScale({series, key: 'type', interpolate: type => {
    return type >= 0.5 ? 'red' : '#ccc';
});

Layout

Because SVG uses a coordinate system originating from the top left the layout function is used to calculate the required widths, padding and flip the y axis.

type layout = (LayoutConfig) => LayoutReturn;

type LayoutConfig = {
    width: number,
    height: number,
    top?: number,
    bottom?: number,
    left?: number,
    right?: number
};

type LayoutReturn = {
    width: number, // Original width - left and right
    height: number, // Original height - top and bottom
    padding: {
        top: number,
        bottom: number,
        left: number,
        right: number
    },
    xRange: [number, number], // tuple from 0 to processed width
    yRange: [number, number], // flipped tuple from processed height to zero
};

// example
import {layout} from 'pnut';

const ll = layout({width: 1280, height: 720, top: 32, bottom: 32, left: 32, right: 32});

const x = ContinuousScale({series, key: 'day', range: ll.xRange});
const y = ContinuousScale({series, key: 'savings', range: ll.yRange, zero: true});

return <Chart {...ll}>
    <Axis scales={scales} position="left" />
    <Axis scales={scales} position="bottom" />
    <Line scales={scales} strokeWidth="2" />
</Chart>;

Renderables

Axis

Render axis based on your series

type Props = {
    // required
    position: Position,
    scales: {
        series: Series,
        x: ContinuousScale|CategoricalScale,
        y: ContinuousScale|CategoricalScale
    },

    // optional
    location?: number | string | Date,

    // style
    strokeWidth: number,
    strokeColor: string,
    textColor: string,
    textSize: number,
    textOffset: number,
    textFormat: (mixed) => string,
    ticks: Function,
    tickLength: number,

    // component
    renderText?: Function,
    renderAxisLine?: Function,
    renderTickLine?: Function
};

Chart

Chart wraps your renderables in an svg tag and applies widths and padding.

type Props = {
    children: Node,
    height: number,
    padding?: {top?: number, bottom?: number, left?: number, right?: number},
    style?: Object,
    width: number
};

Column

Render a column for each point in your series

type Props = {
    // Required scales. Must have a categorical x and a continuous y
    scales: {
        x: CategoricalScale,
        y: ContinuousScale,
        color: ColorScale,
        series: Series
    },
    strokeWidth?: string,
    stroke?: string,
    renderPoint?: Function
};

Interaction

Get information about the closest data points relative to the mouse position.

type Props = {
    scales: {
        series: Series,
        x: ContinuousScale|CategoricalScale,
        y: ContinuousScale|CategoricalScale
    },

    // The height and width from your layout
    height: number,
    width: number,

    // How many times per second to update changes and call props.children or props.onChange
    fps?: number,

    // A render function that is given the current InteractionData.
    children: (InteractionData<A>) => Node

    // A callback fired when the user clicks on the chart somewhere.
    onClick?: (InteractionData<A>) => void,

    // A callback that is fired when the user moves their mouse.
    onChange?: (InteractionData<A>) => void,
};

type InteractionData<A> = {
    // The nearest point in the series to the mouse
    nearestPoint: A,
    
    // For instances like area or column charts the nearest point is not always 
    // the point your mouse is over. Nearest point stepped has a larger threshold 
    // before changing to the next point.
    nearestPointStepped: A,

    // An array of points that are close on the x axis to the mouse.
    // Useful for stacked charts to show all values in a tooltip.
    xPoints: Array<A>,

    // Details on the current mouse position
    position: {
        x: number,
        y: number,
        pageX: number,
        pageY: number,
        clientX: number,
        clientY: number,
        screenX: number,
        screenY: number,
        elementWidth: number,
        elementHeight: number,
        isOver: boolean,
        isDown: boolean
    },
};

Line

Render a set of lines for each group in your series

type Props = {
    scales: {
        x: ContinuousScale,
        y: ContinuousScale,
        color: ColorScale,
        series: Series
    },

    // Render line as an area chart
    area?: boolean,

    // A function that returns the chosen d3 curve generator
    curve?: Function,

    // Set the width of the line that is drawn
    strokeWidth?: string,

    renderGroup?: Function
};

Scatter

Render a series of circles at each data point in your series

type Props = {
    scales: {
        x: ContinuousScale,
        y: ContinuousScale,
        radius: ContinuousScale,
        color: CategoricalScale,
        series: Series
    },

    // Set the outline width and color of each circle
    strokeColor?: string,
    strokeWidth?: string,

    renderPoint?: Function
};

Examples

Line

import {SingleSeries, ContinuousScale, ColorScale, Axis, Line, layout} from 'pnut';

function SavingsOverTime() {
    const {data} = props;
        
    // Define our series with day as the primary dimension
    const series = new SingleSeries({data});

    // calculate chart width, height and padding
    const ll = layout({width: 400, height: 400, left: 32, bottom: 32});

    // Set up scales to define our x, y and color
    const x = ContinuousScale({series, key: 'day', range: ll.xRange});
    const y = ContinuousScale({series, key: 'savings', range: ll.yRange, zero: true});
    const color = ColorScale({series, key: 'savings', set: ['red']});


    // create a scales object for each of our renderable components
    const scales = {series, x, y, color};

    // render a chart with two axis and a line
    return <Chart {...ll}>
        <Axis scales={scales} position="left" />
        <Axis scales={scales} position="bottom" />
        <Line scales={scales} strokeWidth="2" />
    </Chart>;
}

Multi Line

import {Chart, Line, Series, ContinuousScale, ColorScale, Axis, layout} from './src/index';

function MultiLine() {
    const data = [
        {day: 1, type: 'apples', value: 0},
        {day: 2, type: 'apples', value: 10},
        {day: 3, type: 'apples', value: 20},
        {day: 4, type: 'apples', value: 15},
        {day: 5, type: 'apples', value: 200},
        {day: 1, type: 'oranges', value: 200},
        {day: 2, type: 'oranges', value: 50},
        {day: 3, type: 'oranges', value: 30},
        {day: 4, type: 'oranges', value: 24},
        {day: 5, type: 'oranges', value: 150}
    ];

    // Define our series with type as the grouping and day as the primary dimension
    const series = new GroupedSeries({groupKey: 'type', pointKey: 'day', data});

    // calculate chart width, height and padding
    const ll = layout({width: 400, height: 400, left: 32, bottom: 32});

    // Set up scales to define our x, y and color
    const x = ContinuousScale({series, key: 'day', range: ll.xRange});
    const y = ContinuousScale({series, key: 'value', range: ll.yRange, zero: true});
    const color = ColorScale({series, key: 'type', set: ['red', 'orange']});


    // create a scales object for each of our renderable components
    const scales = {series, x, y, color};

    // render a chart with two axis and a line
    return <Chart {...ll}>
        <Axis scales={scales} position="left" />
        <Axis scales={scales} position="bottom" />
        <Line scales={scales} strokeWidth="2" />
    </Chart>;
}

Stacked Area

import {Chart, Line, Series, ContinuousScale, ColorScale, Axis, layout, stack} from './src/index';

function StackedArea() {
    const data = [
        {day: 1, type: 'apples', value: 0},
        {day: 2, type: 'apples', value: 10},
        {day: 3, type: 'apples', value: 20},
        {day: 4, type: 'apples', value: 15},
        {day: 5, type: 'apples', value: 200},
        {day: 1, type: 'oranges', value: 200},
        {day: 2, type: 'oranges', value: 50},
        {day: 3, type: 'oranges', value: 30},
        {day: 4, type: 'oranges', value: 24},
        {day: 5, type: 'oranges', value: 150}
    ];

    // Define our series with type as the grouping and day as the primary dimension
    const series = new GroupedSeries({groupKey: 'type', pointKey: 'day', data});
        .update(stack({key: 'value'})); // stack savings metric

    // calculate chart width, height and padding
    const ll = layout({width: 400, height: 400, left: 32, bottom: 32});

    // Set up scales to define our x, y and color
    const x = ContinuousScale({series, key: 'day', range: ll.xRange});
    const y = ContinuousScale({series, key: 'value', range: ll.yRange, zero: true});
    const color = ColorScale({series, key: 'type', set: ['red', 'orange']});


    // create a scales object for each of our renderable components
    const scales = {series, x, y, color};

    // render a chart with two axis and a line
    return <Chart {...ll}>
        <Axis scales={scales} position="left" />
        <Axis scales={scales} position="bottom" />
        <Line area={true} scales={scales} strokeWidth="2" />
    </Chart>;
}

Column

import {Chart, Column, SingleSeries, CategoricalScale, ContinuousScale, ColorScale, Axis, layout} from './src/index';

function ColumnChart() {
    const data = [
        {fruit: 'apple', count: 20},
        {fruit: 'pears', count: 10},
        {fruit: 'strawberry', count: 30}
    ];

    // Define our series with fruit as the primary dimension
    const series = new SingleSeries({data});

    // calculate chart width, height and padding
    const ll = layout({width: 400, height: 400, left: 32, bottom: 32});

    // Set up scales to define our x,y and color
    const x = CategoricalScale({series, key: 'fruit', range: ll.xRange, padding: 0.1});
    const y = ContinuousScale({series, key: 'count', range: ll.yRange, zero: true});
    const color = ColorScale({series, key: 'fruit', set: ['red', 'green', 'blue']});


    // create a scales object for each of our renderable components
    const scales = {series, x, y, color};

    // render a chart with two axis and a line
    return <Chart {...ll}>
        <Axis scales={scales} position="left" />
        <Axis scales={scales} position="bottom" />
        <Column scales={scales} />
    </Chart>;
}

Stacked Column

import {Chart, Column, Series, CategoricalScale, ContinuousScale, ColorScale, Axis, layout, stack} from './src/index';

function StackedColumn() {
    const data = [
        {day: 1, type: 'apples', value: 10},
        {day: 2, type: 'apples', value: 10},
        {day: 3, type: 'apples', value: 20},
        {day: 4, type: 'apples', value: 15},
        {day: 5, type: 'apples', value: 200},
        {day: 1, type: 'oranges', value: 200},
        {day: 2, type: 'oranges', value: 50},
        {day: 3, type: 'oranges', value: 30},
        {day: 4, type: 'oranges', value: 24},
        {day: 5, type: 'oranges', value: 150}
    ];

    // Define our series with day as the primary dimension
    const series = new GroupedSeries({groupKey: 'type', pointKey: 'day', data});
        .update(stack({key: 'value'})); // stack savings metric

    // calculate chart width, height and padding
    const ll = layout({width: 400, height: 400, left: 32, bottom: 32});

    // Set up scales to define our x,y and color
    const x = CategoricalScale({series, key: 'day', range: ll.xRange, padding: 0.1});
    const y = ContinuousScale({series, key: 'value', range: ll.yRange, zero: true});
    const color = ColorScale({series, key: 'type', set: ['red', 'green']});


    // create a scales object for each of our renderable components
    const scales = {series, x, y, color};

    // render a chart with two axis and a line
    return <Chart {...ll}>
        <Axis scales={scales} position="left" />
        <Axis scales={scales} position="bottom" />
        <Column scales={scales} />
    </Chart>;
}

Grouped Column

import {Chart, Column, Series, CategoricalScale, ContinuousScale, ColorScale, Axis, layout} from './src/index';

function GroupedColumn() {
    const data = [
        {day: 1, type: 'apples', value: 10},
        {day: 2, type: 'apples', value: 10},
        {day: 3, type: 'apples', value: 20},
        {day: 4, type: 'apples', value: 15},
        {day: 5, type: 'apples', value: 200},
        {day: 1, type: 'oranges', value: 200},
        {day: 2, type: 'oranges', value: 50},
        {day: 3, type: 'oranges', value: 30},
        {day: 4, type: 'oranges', value: 24},
        {day: 5, type: 'oranges', value: 150}
    ];

    // Define our series with day as the primary dimension
    const series = new GroupedSeries({groupKey: 'type', pointKey: 'day', data});

    // calculate chart width, height and padding
    const ll = layout({width: 400, height: 400, left: 32, bottom: 32});

    // Set up scales to define our x,y and color
    const x = CategoricalScale({series, key: 'day', range: ll.xRange, padding: 0.1});
    const y = ContinuousScale({series, key: 'value', range: ll.yRange, zero: true});
    const color = ColorScale({series, key: 'type', set: ['red', 'green']});


    // create a scales object for each of our renderable components
    const scales = {series, x, y, color};

    // render a chart with two axis and a line
    return <Chart {...ll}>
        <Axis scales={scales} position="left" />
        <Axis scales={scales} position="bottom" />
        <Column scales={scales} />
    </Chart>;
}

Scatter

import {Chart, Scatter, Series, ContinuousScale, ColorScale, Axis, layout} from './src/index';

function ScatterChart() {
    const data = [
        {day: 1, type: 'apples', value: 0},
        {day: 2, type: 'apples', value: 10},
        {day: 3, type: 'apples', value: 20},
        {day: 4, type: 'apples', value: 15},
        {day: 5, type: 'apples', value: 200},
        {day: 1, type: 'oranges', value: 200},
        {day: 2, type: 'oranges', value: 50},
        {day: 3, type: 'oranges', value: 30},
        {day: 4, type: 'oranges', value: 24},
        {day: 5, type: 'oranges', value: 150}
    ];

    // Define our series with day as the primary dimension
    const series = new GroupedSeries({groupKey: 'type', pointKey: 'day', data});

    // calculate chart width, height and padding
    const ll = layout({width: 400, height: 400, left: 32, bottom: 32});

    // Set up scales to define our x, y and color
    const x = ContinuousScale({series, key: 'day', range: ll.xRange});
    const y = ContinuousScale({series, key: 'value', range: ll.yRange});
    const radius = ContinuousScale({series, key: 'value', range: [2, 2]});
    const color = ColorScale({series, key: 'type', set: ['red', 'orange']});


    // create a scales object for each of our renderable components
    const scales = {series, x, y, radius, color};

    // render a chart with two axis and a line
    return <Chart {...ll}>
        <Axis scales={scales} position="left" />
        <Axis scales={scales} position="bottom" />
        <Scatter scales={scales} strokeWidth="2" />
    </Chart>;
}

Bubble

import {Chart, Scatter, Series, ContinuousScale, ColorScale, Axis, layout} from './src/index';

function BubbleChart() {
    const data = [
        {day: 1, size: 200, value: 0},
        {day: 2, size: 800, value: 10},
        {day: 3, size: 900, value: 20},
        {day: 4, size: 200, value: 15},
        {day: 5, size: 300, value: 200},
        {day: 6, size: 400, value: 100},
        {day: 7, size: 300, value: 20}
    ];

    // Define our series with type as the grouping and day as the primary dimension
    const series = new GroupedSeries({groupKey: 'type', pointKey: 'day', data});

    // calculate chart width, height and padding
    const ll = layout({width: 400, height: 400, left: 32, bottom: 32});

    // Set up scales to define our x, y and color
    const x = ContinuousScale({series, key: 'day', range: ll.xRange});
    const y = ContinuousScale({series, key: 'value', range: ll.yRange});
    const radius = ContinuousScale({series, key: 'size', range: [2, 10]});
    const color = ColorScale({series, key: 'type', set: ['orange']});


    // create a scales object for each of our renderable components
    const scales = {series, x, y, radius, color};

    // render a chart with two axis and a line
    return <Chart {...ll}>
        <Axis scales={scales} position="left" />
        <Axis scales={scales} position="bottom" />
        <Scatter scales={scales} strokeWidth="2" />
    </Chart>;
}

Todo

  • Bar
  • Pie
  • Histogram
  • BinnedSeries