Created
June 15, 2025 23:03
-
-
Save thesephist/0d02d52dffb89c224f948f5c946bb593 to your computer and use it in GitHub Desktop.
react-email/chart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Column, Heading, Row } from '@react-email/components'; | |
import React, { CSSProperties, Fragment } from 'react'; | |
import { ResponsiveRow } from './ResponsiveRow'; | |
export type ChartProps<T> = { | |
// Content | |
items: T[]; | |
getKey: (item: T) => string; | |
getValue: (item: T) => number; | |
getLabel: (item: T) => React.ReactNode; | |
isItemHighlighted?: (item: T) => boolean; | |
// Layout | |
title: React.ReactNode; | |
minYLabels: number; | |
chartHeight: number; | |
}; | |
/** | |
* Renders a simple bar chart using HTML tables in a way that renders reasonably | |
* well in most email clients. | |
*/ | |
export function Chart<T>(props: ChartProps<T>) { | |
const { items, getKey, getValue, getLabel, isItemHighlighted, title, minYLabels, chartHeight } = | |
props; | |
const values = items.map(getValue); | |
const maxValue = Math.max(...values, 10); | |
const increment = getLargestIncrementWithAtLeastNLabels({ | |
maxValue, | |
minNumLabels: minYLabels, | |
}); | |
const yAxisMax = Math.ceil(maxValue / increment) * increment; | |
const yAxisTickCount = yAxisMax / increment; | |
const yAxisTicks = Array.from({ length: yAxisTickCount }, (_, i) => i * increment); | |
const chartBarValueLabelPadding = 20; | |
return ( | |
<> | |
<Heading as="h3" className="text-base font-medium my-2"> | |
{title} | |
</Heading> | |
<Row> | |
<Column | |
style={{ | |
verticalAlign: 'top', | |
height: chartHeight, | |
paddingTop: chartBarValueLabelPadding + chartHeight / yAxisTicks.length / 2 - 2, | |
}} | |
> | |
{yAxisTicks.map((_tick, index) => ( | |
<div | |
key={index} | |
style={{ | |
height: `${chartHeight / yAxisTicks.length}px`, | |
textAlign: 'right', | |
}} | |
> | |
<table className="h-full w-full"> | |
<tr className="h-full w-full"> | |
<td className="h-full w-full text-xs text-gray-500 whitespace-nowrap"> | |
{yAxisTicks[yAxisTicks.length - index - 1]} — | |
</td> | |
</tr> | |
</table> | |
</div> | |
))} | |
</Column> | |
<Column> | |
<table | |
width="100%" | |
cellPadding="0" | |
cellSpacing="0" | |
style={{ | |
tableLayout: 'fixed', | |
height: chartHeight, | |
}} | |
> | |
<tr style={{ height: chartHeight }}> | |
{items.map((it, index) => { | |
const value = values[index]; | |
const barHeight = value > 0 ? Math.max((value / maxValue) * chartHeight, 1) : 0; | |
return ( | |
<td key={getKey(it)}> | |
<div | |
style={{ | |
width: '70%', | |
height: chartBarValueLabelPadding + chartHeight, | |
margin: '0 auto', | |
}} | |
> | |
{/* Empty & label segment */} | |
<div | |
className="text-xs text-gray-500" | |
style={{ | |
height: | |
chartHeight + | |
chartBarValueLabelPadding - | |
barHeight - | |
(value === 0 ? 1 : 0), | |
width: '100%', | |
backgroundColor: 'transparent', | |
textAlign: 'center', | |
}} | |
> | |
<table className="h-full w-full"> | |
<tr className="h-full w-full"> | |
<td | |
className="h-full w-full" | |
style={{ | |
verticalAlign: 'bottom', | |
textAlign: 'center', | |
}} | |
> | |
{value} | |
</td> | |
</tr> | |
</table> | |
</div> | |
{/* Bar */} | |
<div | |
style={{ | |
height: `${barHeight}px`, | |
minHeight: 1, | |
width: '100%', | |
backgroundColor: isItemHighlighted?.(it) ? '#b3121f' : '#AAAAAA', | |
borderRadius: '3px', | |
}} | |
></div> | |
</div> | |
</td> | |
); | |
})} | |
</tr> | |
</table> | |
<table className="table-fixed w-full"> | |
<tr> | |
{items.map((it) => ( | |
<td key={getKey(it)} align="center"> | |
<div | |
className="text-xxs text-gray-500 whitespace-nowrap" | |
style={{ marginTop: '3px' }} | |
> | |
{getLabel(it)} | |
</div> | |
</td> | |
))} | |
</tr> | |
</table> | |
</Column> | |
</Row> | |
</> | |
); | |
} | |
export function getLargestIncrementWithAtLeastNLabels(args: { | |
maxValue: number; | |
minNumLabels: number; | |
}): number { | |
const { maxValue, minNumLabels } = args; | |
const choices = [1000, 500, 250, 100, 50, 25, 10, 5, 2, 1]; | |
for (const choice of choices) { | |
const numLabels = Math.ceil(maxValue / choice); | |
if (numLabels >= minNumLabels) { | |
return choice; | |
} | |
} | |
return choices[0]; | |
} | |
export function getPercentiles(values: number[], percentiles: number[]): number[] { | |
const sortedValues = values.sort((a, b) => a - b); | |
return percentiles.map((p) => sortedValues[Math.floor(p * sortedValues.length - 1)]); | |
} | |
export function computeBuckets<T>(args: { | |
items: T[]; | |
numBuckets: number; | |
getValue: (item: T) => number; | |
displayValue: (value: number) => string; | |
}): { | |
count: number; | |
label: string; | |
isMode: boolean; | |
}[] { | |
const { items, numBuckets, getValue, displayValue } = args; | |
if (items.length === 0) { | |
return []; | |
} | |
const values = items.map(getValue); | |
const minValue = Math.min(...values); | |
const maxValue = Math.max(...values); | |
if (minValue === maxValue) { | |
return [ | |
{ | |
count: items.length, | |
label: displayValue(minValue), | |
isMode: true, | |
}, | |
]; | |
} | |
const bucketSize = (maxValue - minValue) / numBuckets; | |
const bucketRanges = Array.from({ length: numBuckets }, (_, i) => ({ | |
min: minValue + i * bucketSize, | |
max: minValue + (i + 1) * bucketSize, | |
})); | |
const bucketCounts = bucketRanges.map((range, i) => ({ | |
range, | |
count: values.filter( | |
(value) => | |
value >= range.min && | |
(i === bucketRanges.length - 1 ? value <= range.max : value < range.max), | |
).length, | |
})); | |
const maxCount = Math.max(...bucketCounts.map((b) => b.count)); | |
const buckets = bucketCounts.map((bucket) => ({ | |
count: bucket.count, | |
label: `< ${displayValue(bucket.range.max)}`, | |
isMode: bucket.count === maxCount, | |
})); | |
return buckets; | |
} | |
export type SegmentedChartProps<T, C extends string> = { | |
items: T[]; | |
segments: C[]; | |
getKey: (item: T) => string; | |
getValue: (item: T, segment: C) => number; | |
getLabel: (item: T, index: number) => React.ReactNode; | |
getSegmentLabel: (segment: C) => string; | |
getSegmentColor: (segment: C) => string; | |
title: React.ReactNode; | |
minYLabels: number; | |
chartHeight: number; | |
}; | |
export function SegmentedChart<T, C extends string>(props: SegmentedChartProps<T, C>) { | |
const { | |
items, | |
segments, | |
getKey, | |
getValue, | |
getLabel, | |
getSegmentLabel, | |
getSegmentColor, | |
title, | |
minYLabels, | |
chartHeight, | |
} = props; | |
const totals = items.map((item) => { | |
const values = segments.map((segment) => getValue(item, segment)); | |
return { | |
item, | |
total: values.reduce((a, b) => a + b, 0), | |
values, | |
}; | |
}); | |
const maxTotal = totals.reduce((a, b) => Math.max(a, b.total), 0); | |
const increment = getLargestIncrementWithAtLeastNLabels({ | |
maxValue: maxTotal, | |
minNumLabels: minYLabels, | |
}); | |
const yAxisMax = Math.ceil(maxTotal / increment) * increment; | |
const yAxisTickCount = yAxisMax / increment; | |
const yAxisTicks = Array.from({ length: yAxisTickCount }, (_, i) => i * increment); | |
const chartLegendPadding = 0; | |
const chartBarValueLabelPadding = 20; | |
const legendSquareStyles: CSSProperties = { | |
width: '12px', | |
height: '12px', | |
borderRadius: '2px', | |
display: 'inline-block', | |
verticalAlign: 'middle', | |
marginLeft: 4, | |
marginRight: 4, | |
}; | |
return ( | |
<> | |
<ResponsiveRow className="my-2"> | |
<Column> | |
<Heading as="h3" className="text-base font-medium my-0"> | |
{title} | |
</Heading> | |
</Column> | |
<Column align="right"> | |
{segments.map((seg, index) => { | |
return ( | |
<Fragment key={seg}> | |
{index > 0 && <div className="inline-block w-1" />} | |
<div | |
style={{ | |
...legendSquareStyles, | |
backgroundColor: getSegmentColor(seg), | |
}} | |
></div> | |
<span className="text-xs text-gray-600">{getSegmentLabel(seg)}</span> | |
</Fragment> | |
); | |
})} | |
</Column> | |
</ResponsiveRow> | |
<Row> | |
<Column | |
style={{ | |
verticalAlign: 'top', | |
height: chartHeight, | |
paddingTop: | |
chartLegendPadding + | |
chartBarValueLabelPadding + | |
chartHeight / yAxisTicks.length / 2 - | |
2, | |
}} | |
> | |
{yAxisTicks.map((_tick, index) => ( | |
<div | |
key={index} | |
style={{ | |
height: `${chartHeight / yAxisTicks.length}px`, | |
textAlign: 'right', | |
}} | |
> | |
<table className="h-full w-full"> | |
<tr className="h-full w-full"> | |
<td className="h-full w-full text-xs text-gray-500 whitespace-nowrap"> | |
{yAxisTicks[yAxisTicks.length - index - 1]} — | |
</td> | |
</tr> | |
</table> | |
</div> | |
))} | |
</Column> | |
<Column> | |
<table | |
width="100%" | |
cellPadding="0" | |
cellSpacing="0" | |
style={{ | |
tableLayout: 'fixed', | |
height: chartHeight, | |
}} | |
> | |
<tr style={{ height: chartHeight }}> | |
{items.map((it, index) => { | |
const zeroBarHeight = 2; | |
const barSegmentPadding = 2; | |
const heights: Partial<Record<C, number>> = {}; | |
for (const seg of segments) { | |
heights[seg] = (getValue(it, seg) / yAxisMax) * chartHeight; | |
} | |
let visibleBarHeight = 0; | |
const visibleSegments = segments.filter((seg) => heights[seg]); | |
if (visibleSegments.length > 0) { | |
for (const seg of visibleSegments) { | |
visibleBarHeight += heights[seg] ?? 0; | |
} | |
visibleBarHeight += barSegmentPadding * (visibleSegments.length - 1); | |
} else { | |
visibleBarHeight = zeroBarHeight; | |
} | |
return ( | |
<td key={getKey(it)}> | |
<div | |
style={{ | |
width: '70%', | |
height: chartBarValueLabelPadding + chartHeight, | |
margin: '0 auto', | |
}} | |
> | |
{/* Empty & label segment */} | |
<div | |
className="text-xs text-gray-500" | |
style={{ | |
height: chartHeight + chartBarValueLabelPadding - visibleBarHeight, | |
width: '100%', | |
backgroundColor: 'transparent', | |
textAlign: 'center', | |
}} | |
> | |
<table className="h-full w-full"> | |
<tr className="h-full w-full"> | |
<td | |
className="h-full w-full" | |
style={{ | |
verticalAlign: 'bottom', | |
textAlign: 'center', | |
}} | |
> | |
{totals[index].total} | |
</td> | |
</tr> | |
</table> | |
</div> | |
{/* Bar segments */} | |
{segments.map((seg, index) => { | |
const isFirstVisibleSegment = | |
segments.findIndex((s) => heights[s]) === index; | |
const isLastVisibleSegment = | |
findLastIndex(segments, (s) => heights[s]) === index; | |
function getBorderRadius() { | |
if (isFirstVisibleSegment && isLastVisibleSegment) { | |
return '3px'; | |
} else if (isFirstVisibleSegment) { | |
return '3px 3px 0 0'; | |
} else if (isLastVisibleSegment) { | |
return '0 0 3px 3px'; | |
} else { | |
return '0'; | |
} | |
} | |
function getMarginTop() { | |
if (!isFirstVisibleSegment) { | |
return barSegmentPadding; | |
} else { | |
return 0; | |
} | |
} | |
function getMarginBottom() { | |
if (!isLastVisibleSegment) { | |
return barSegmentPadding; | |
} else { | |
return 0; | |
} | |
} | |
if (heights[seg]) { | |
return ( | |
<div | |
key={seg} | |
style={{ | |
height: heights[seg], | |
width: '100%', | |
backgroundColor: getSegmentColor(seg), | |
borderRadius: getBorderRadius(), | |
marginTop: getMarginTop(), | |
marginBottom: getMarginBottom(), | |
}} | |
></div> | |
); | |
} | |
return null; | |
})} | |
{/* Empty bar */} | |
{totals[index].total === 0 && ( | |
<div | |
style={{ | |
width: '100%', | |
backgroundColor: '#ddd', | |
borderRadius: 3, | |
height: visibleBarHeight, | |
}} | |
/> | |
)} | |
</div> | |
</td> | |
); | |
})} | |
</tr> | |
</table> | |
<table className="table-fixed w-full border-collapse"> | |
<tr> | |
{items.map((it, index) => ( | |
<td key={getKey(it)} align="center" valign="top" style={{ paddingTop: '6px' }}> | |
<div className="text-xxs text-gray-500 whitespace-nowrap"> | |
{getLabel(it, index)} | |
</div> | |
</td> | |
))} | |
</tr> | |
</table> | |
</Column> | |
</Row> | |
</> | |
); | |
} | |
function findLastIndex<T>(array: T[], predicate: (item: T) => unknown): number { | |
for (let i = array.length - 1; i >= 0; i--) { | |
if (predicate(array[i])) { | |
return i; | |
} | |
} | |
return -1; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from 'react'; | |
import { Row } from '@react-email/components'; | |
/** | |
* Wraps React Email's `Row` primitive with behavior that makes it turn into a | |
* vertically stacked list of `Column`s on small screens. | |
* | |
* See also <style> in {@link index.tsx} for accompanying CSS implementation. | |
*/ | |
export function ResponsiveRow(props: { | |
className?: string; | |
fixed?: boolean; | |
leading?: 'tight' | 'normal'; | |
style?: React.CSSProperties; | |
children: React.ReactNode; | |
}) { | |
const { className, fixed, children, leading = 'normal', style } = props; | |
return ( | |
<Row | |
className={`responsive-row ${fixed ? 'table-fixed' : ''} ${className ?? ''} ${ | |
leading === 'tight' ? 'tight' : '' | |
}`} | |
style={style} | |
> | |
{children} | |
</Row> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment