Skip to content

Commit

Permalink
feat: support gradients in line charts
Browse files Browse the repository at this point in the history
  • Loading branch information
sgratzl committed Oct 24, 2021
1 parent e364943 commit f553a98
Show file tree
Hide file tree
Showing 7 changed files with 135 additions and 50 deletions.
2 changes: 1 addition & 1 deletion packages/components/src/components/LineChart.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@
transform: translate(-50%, -50%);
transform-origin: center center;
background-color: var(--current-color, black);
}
}
130 changes: 97 additions & 33 deletions packages/components/src/components/LineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
* Copyright (c) 2021 Samuel Gratzl <[email protected]>
*/

import React, {Fragment} from 'react';
import React, { Fragment } from 'react';
import { defaultColorScale } from '../math';
import type { CommonProps } from './common';
import type { HeatMap1DProps } from './HeatMap1D';
import { clsx, toLocaleString, toPercent } from './utils';
import type { CommonNumbersProps, HeatMap1DProps } from './HeatMap1D';
import { clsx, generateGradient, toLocaleString, toPercent } from './utils';

export interface LineChartProps extends HeatMap1DProps {
/**
Expand All @@ -20,6 +19,10 @@ export interface LineChartProps extends HeatMap1DProps {
* fill the line chart at the bottom
*/
fill?: boolean;
/**
* use color gradient for stroke and fill
*/
gradient?: boolean;
}

const width = 100;
Expand All @@ -36,8 +39,8 @@ function generateLine(
return '';
}
const prefix = i === 0 || vs[i - 1] == null ? ' M' : ' L';
const x = (i * xScale) * width;
const y = (1-yScale(v)) * height;
const x = i * xScale * width;
const y = (1 - yScale(v)) * height;
return `${prefix}${x},${y}`;
})
.join('');
Expand All @@ -49,7 +52,7 @@ function generateArea(
yScale: (v: number) => number
): string {
let last = -1;
let segments: string[] = [];
const segments: string[] = [];

vs.forEach((v, i) => {
if (v == null) {
Expand All @@ -75,6 +78,27 @@ function generateArea(
return segments.join(' ');
}

function calculateGradient(
suffix: string,
values: readonly (number | null | undefined)[],
yScale: (v: number) => number,
color: LineChartProps['color']
) {
if (typeof color === 'string') {
return {
value: color,
elem: null,
};
} else if (typeof color === 'function') {
const colors = values.map((v, i) => (v == null ? null : color(yScale(v), i)));
return generateGradient(`lt-line-chart-g${suffix}`, colors, 0, width);
}
return {
value: undefined,
elem: null,
};
}

/**
* renders a line chart
*/
Expand All @@ -84,21 +108,47 @@ export function LineChart(props: LineChartProps): JSX.Element {
const yScale = typeof props.scale === 'function' ? props.scale : (v: number) => v;
const colorScale = typeof props.color === 'string' ? () => props.color as string : props.color ?? defaultColorScale;

const gradient = props.gradient
? calculateGradient('', values, yScale, props.color)
: { value: undefined, elem: null };
const gradientPreFiltered =
props.gradient && props.preFilter
? calculateGradient('pre', props.preFilter, yScale, props.color)
: { value: undefined, elem: null };

return (
<div className={clsx('lt-line-chart', props.className)} style={props.style}>
{typeof props.format === 'string' && <span aria-hidden="false">{props.format}</span>}
<svg
viewBox={`0 0 ${width} ${height}`}
className={'lt-line-chart-container'}
preserveAspectRatio="none"
>
<svg viewBox={`0 0 ${width} ${height}`} className={'lt-line-chart-container'} preserveAspectRatio="none">
{gradient.elem}
{gradientPreFiltered.elem}
{typeof props.format === 'string' && <title>{props.format}</title>}
{props.fill && props.preFilter && <path className="lt-line-chart-area lt-line-chart-area-pre" d={generateArea(props.preFilter, xScale, yScale)} />}
{props.fill && props.preFilter && (
<path
className="lt-line-chart-area lt-line-chart-area-pre"
d={generateArea(props.preFilter, xScale, yScale)}
style={{ fill: gradientPreFiltered.value }}
/>
)}
{props.preFilter && (
<path className="lt-line-chart-line lt-line-chart-pre" d={generateLine(props.preFilter, xScale, yScale)} />
<path
className="lt-line-chart-line lt-line-chart-pre"
d={generateLine(props.preFilter, xScale, yScale)}
style={{ stroke: gradientPreFiltered.value }}
/>
)}
{props.fill && <path className="lt-line-chart-area" d={generateArea(values, xScale, yScale)} />}
<path className="lt-line-chart-line" d={generateLine(values, xScale, yScale)} />
{props.fill && (
<path
className="lt-line-chart-area"
d={generateArea(values, xScale, yScale)}
style={{ fill: gradient.value }}
/>
)}
<path
className="lt-line-chart-line"
d={generateLine(values, xScale, yScale)}
style={{ stroke: gradient.value }}
/>
</svg>
<div className="lt-line-chart-points">
{values.map((v, i) => {
Expand All @@ -115,7 +165,7 @@ export function LineChart(props: LineChartProps): JSX.Element {
style={{
backgroundColor: color,
left: toPercent(i * xScale),
top: toPercent(1 - normalized)
top: toPercent(1 - normalized),
}}
title={label}
/>
Expand All @@ -126,17 +176,18 @@ export function LineChart(props: LineChartProps): JSX.Element {
);
}

export interface MultiLineChartProps extends CommonProps {
export interface MultiLineChartProps extends CommonNumbersProps {
value: readonly (readonly (number | null | undefined)[])[];
/**
* optional scale to convert the number in the 0..1 range
*/
scale?: (v: number) => number;
/**
* fill the line chart at the bottom
*/
fill?: boolean;
/**
* use color gradient for stroke and fill
*/
gradient?: boolean;
}

/**
* renders multiple line charts
*/
Expand All @@ -145,17 +196,30 @@ export function MultiLineChart(props: MultiLineChartProps): JSX.Element {
const xScale = 1 / (maxX - 1);
const yScale = typeof props.scale === 'function' ? props.scale : (v: number) => v;
return (
<div className={clsx('lt-line-chart', props.className)}
style={props.style}>
<svg
viewBox={`0 0 ${width} ${height}`}
className={'lt-line-chart-container'}
preserveAspectRatio="none"
>
{props.value.map((vs, i) => (<Fragment key={i}>
{props.fill && <path className="lt-line-chart-area" d={generateArea(vs ?? [], xScale, yScale)} />}
<path className="lt-line-chart-line" d={generateLine(vs ?? [], xScale, yScale)} />
</Fragment>))}
<div className={clsx('lt-line-chart', props.className)} style={props.style}>
<svg viewBox={`0 0 ${width} ${height}`} className={'lt-line-chart-container'} preserveAspectRatio="none">
{props.value.map((vs, i) => {
const gradient = props.gradient
? calculateGradient('', vs, yScale, props.color)
: { value: undefined, elem: null };
return (
<Fragment key={i}>
{gradient.elem}
{props.fill && (
<path
className="lt-line-chart-area"
d={generateArea(vs ?? [], xScale, yScale)}
style={{ fill: gradient.value }}
/>
)}
<path
className="lt-line-chart-line"
d={generateLine(vs ?? [], xScale, yScale)}
style={{ stroke: gradient.value }}
/>
</Fragment>
);
})}
</svg>
</div>
);
Expand Down
12 changes: 3 additions & 9 deletions packages/components/src/components/UpSetLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,9 @@ function UpSetLineLine({
stroke = color;
} else if (typeof color === 'function') {
const colors = sets.slice(first, last + 1).map((d) => color(d));
if (colors.every((d) => d === colors[0])) {
// single color
// eslint-disable-next-line prefer-destructuring
stroke = colors[0];
} else {
const r = generateGradient('lt-upset-line-g', colors, first * 2, last * 2);
stroke = r.url;
g = r.def;
}
const r = generateGradient('lt-upset-line-g', colors, first * 2, last * 2);
stroke = r.value;
g = r.elem;
}
return (
<svg
Expand Down
30 changes: 23 additions & 7 deletions packages/components/src/components/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,32 @@ export function useI18N<T extends Record<string, string>>(
}, [original, overrides]);
}

export function generateGradient(prefix: string, colors: string[], x1: number, x2: number) {
const id = `${prefix}-${colors.join(',')}`.replace(/[, ]+/gm, '').replace(/[-#$()[\]{}"']+/gm, '-');
export function hashCode(s: string) {
// based on https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
return s.split('').reduce((a, b) => {
const next = (a << 5) - a + b.charCodeAt(0);
return next & next;
}, 0);
}

export function generateGradient(prefix: string, colors: (string | null | undefined)[], x1: number, x2: number) {
if (colors.length === 0 || colors.every((d) => d === colors[0])) {
// single color
// eslint-disable-next-line prefer-destructuring
return {
value: colors[0]!,
elem: <></>,
};
}
const id = `${prefix}-${hashCode(colors.join(','))}`;
return {
url: `url('#${id}')`,
def: (
value: `url('#${id}')`,
elem: (
<defs>
<linearGradient id={id} x1={x1} x2={x2} gradientUnits="userSpaceOnUse">
{colors.map((d, i) => (
<stop key={d} offset={toPercent(i / (colors.length - 1))} stopColor={d} />
))}
{colors.map((d, i) =>
d == null ? null : <stop key={d} offset={toPercent(i / (colors.length - 1))} stopColor={d} />
)}
</linearGradient>
</defs>
),
Expand Down
8 changes: 8 additions & 0 deletions packages/docs/docs/components/numbers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ function Example() {
<LineChart value={data[0]} scale={stats.scale} color={stats.color} format={stats.format} style={{ height: 25 }} fill/>
<LineChart value={data[1]} scale={stats.scale} color={stats.color} format={stats.format} style={{ height: 25 }} fill/>
<LineChart value={data[2]} scale={stats.scale} color={stats.color} format={stats.format} style={{ height: 25 }} fill/>

<LineChart value={data[0]} scale={stats.scale} color={stats.color} format={stats.format} style={{ height: 25 }} gradient/>
<LineChart value={data[1]} scale={stats.scale} color={stats.color} format={stats.format} style={{ height: 25 }} gradient/>
<LineChart value={data[2]} scale={stats.scale} color={stats.color} format={stats.format} style={{ height: 25 }} gradient/>

<LineChart value={data[0]} scale={stats.scale} color={stats.color} format={stats.format} style={{ height: 25 }} fill gradient/>
<LineChart value={data[1]} scale={stats.scale} color={stats.color} format={stats.format} style={{ height: 25 }} fill gradient/>
<LineChart value={data[2]} scale={stats.scale} color={stats.color} format={stats.format} style={{ height: 25 }} fill gradient/>
</div>
);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/hooks/src/renderers/LineChartRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { missingClass, optionContext } from './utils';

export interface LineChartRendererOptions extends BarRendererOptions {
fill?: boolean;
gradient?: boolean;
}

export function LineChartRenderer<D extends UnknownObject, P extends CellProps<D, (number | null | undefined)[]>>(
Expand All @@ -27,6 +28,7 @@ export function LineChartRenderer<D extends UnknownObject, P extends CellProps<D
{...p}
value={props.value}
fill={options.fill}
gradient={options.gradient}
style={options.style}
className={clsx(missingClass(props.value), options.className)}
/>
Expand Down
1 change: 1 addition & 0 deletions packages/hooks/src/renderers/MultiLineChartRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function MultiLineChartRenderer<D extends UnknownObject, P extends CellPr
{...p}
value={props.value}
fill={options.fill}
gradient={options.gradient}
style={options.style}
className={clsx(missingClass(props.value), options.className)}
/>
Expand Down

0 comments on commit f553a98

Please sign in to comment.