feat: lazy-load charts, avoid ssr

This commit is contained in:
Vyacheslav Matyukhin 2022-05-07 22:12:19 +04:00
parent da66ea05f8
commit 8c5ed35c0f
No known key found for this signature in database
GPG Key ID: 3D2A774C5489F96C
5 changed files with 351 additions and 304 deletions

View File

@ -1,304 +0,0 @@
import {
addDays, differenceInDays, format, startOfDay, startOfToday, startOfTomorrow
} from "date-fns";
import React, { useMemo, useState } from "react";
import {
VictoryAxis, VictoryChart, VictoryGroup, VictoryLabel, VictoryLine, VictoryScatter,
VictoryTheme, VictoryTooltip, VictoryVoronoiContainer
} from "victory";
import { QuestionWithHistoryFragment } from "../../fragments.generated";
interface Props {
question: QuestionWithHistoryFragment;
}
type DataSet = { x: Date; y: number; name: string }[];
const MAX_LINES = 5;
// number of colors should match MAX_LINES
// colors are taken from tailwind, https://tailwindcss.com/docs/customizing-colors
const colors = [
"#0284C7", // sky-600
"#DC2626", // red-600
"#15803D", // green-700
"#7E22CE", // purple-700
"#F59E0B", // amber-500
];
// can't be replaced with React component, VictoryChart requires VictoryGroup elements to be immediate children
const getVictoryGroup = ({
data,
i,
highlight,
}: {
data: DataSet;
i: number;
highlight?: boolean;
}) => {
return (
<VictoryGroup color={colors[i] || "darkgray"} data={data} key={i}>
<VictoryLine
name={`line-${i}`}
style={{
data: {
strokeOpacity: highlight ? 1 : 0.5,
},
}}
/>
<VictoryScatter
name={`scatter-${i}`}
size={({ active }) => (active || highlight ? 3.75 : 3)}
/>
</VictoryGroup>
);
};
const Legend: React.FC<{
items: { name: string; color: string }[];
setHighlight: (i: number | undefined) => void;
}> = ({ items, setHighlight }) => {
return (
<div className="space-y-2" onMouseLeave={() => setHighlight(undefined)}>
{items.map((item, i) => (
<div
className="flex items-center"
key={item.name}
onMouseOver={() => setHighlight(i)}
>
<svg className="mt-1 shrink-0" height="10" width="16">
<circle cx="4" cy="4" r="4" fill={item.color} />
</svg>
<span className="text-xs sm:text-sm sm:whitespace-nowrap sm:text-ellipsis sm:overflow-hidden sm:max-w-160">
{item.name}
</span>
</div>
))}
</div>
);
};
const buildDataSets = (question: QuestionWithHistoryFragment) => {
let dataSetsNames = question.options
.sort((a, b) => {
if (a.probability > b.probability) {
return -1;
} else if (a.probability < b.probability) {
return 1;
}
return a.name < b.name ? -1 : 1; // needed for stable sorting - otherwise it's possible to get order mismatch in SSR vs client-side
})
.map((o) => o.name)
.slice(0, MAX_LINES);
const isBinary =
(dataSetsNames[0] === "Yes" && dataSetsNames[1] === "No") ||
(dataSetsNames[0] === "No" && dataSetsNames[1] === "Yes");
if (isBinary) {
dataSetsNames = ["Yes"];
}
const nameToIndex = Object.fromEntries(
dataSetsNames.map((name, i) => [name, i])
);
let dataSets: DataSet[] = [...Array(dataSetsNames.length)].map((x) => []);
const sortedHistory = question.history.sort((a, b) =>
a.timestamp < b.timestamp ? -1 : 1
);
{
let previousDate = -Infinity;
for (const item of sortedHistory) {
if (item.timestamp - previousDate < 12 * 60 * 60) {
continue;
}
const date = new Date(item.timestamp * 1000);
for (const option of item.options) {
const idx = nameToIndex[option.name];
if (idx === undefined) {
continue;
}
const result = {
x: date,
y: option.probability,
name: option.name,
};
dataSets[idx].push(result);
}
previousDate = item.timestamp;
}
}
let maxProbability = 0;
for (const dataSet of dataSets) {
for (const item of dataSet) {
maxProbability = Math.max(maxProbability, item.y);
}
}
const minDate = sortedHistory.length
? startOfDay(new Date(sortedHistory[0].timestamp * 1000))
: startOfToday();
const maxDate = sortedHistory.length
? addDays(
startOfDay(
new Date(sortedHistory[sortedHistory.length - 1].timestamp * 1000)
),
1
)
: startOfTomorrow();
const result = {
dataSets,
dataSetsNames,
maxProbability,
minDate,
maxDate,
};
return result;
};
export const HistoryChart: React.FC<Props> = ({ question }) => {
const [highlight, setHighlight] = useState<number | undefined>(undefined);
const { dataSets, dataSetsNames, maxProbability, minDate, maxDate } = useMemo(
() => buildDataSets(question),
[question]
);
const domainMax =
maxProbability < 0.5 ? Math.round(10 * (maxProbability + 0.05)) / 10 : 1;
const goldenRatio = (1 + Math.sqrt(5)) / 2;
const width = 750;
const height = width / goldenRatio;
const padding = {
top: 20,
bottom: 60,
left: 60,
right: 20,
};
return (
<div className="flex items-center flex-col sm:flex-row">
<VictoryChart
domainPadding={20}
padding={padding}
theme={VictoryTheme.material}
height={height}
width={width}
containerComponent={
<VictoryVoronoiContainer
labels={() => "Not shown"}
labelComponent={
<VictoryTooltip
constrainToVisibleArea
pointerLength={0}
dy={-12}
labelComponent={
<VictoryLabel
style={[
{
fontSize: 18,
fill: "black",
strokeWidth: 0.05,
},
{
fontSize: 18,
fill: "#777",
strokeWidth: 0.05,
},
]}
/>
}
text={({ datum }) =>
`${datum.name}: ${Math.round(datum.y * 100)}%\n${format(
datum.x,
"yyyy-MM-dd"
)}`
}
style={{
fontSize: 18, // needs to be set here and not just in labelComponent for text size calculations
fontFamily:
'"Gill Sans", "Gill Sans MT", "Ser­avek", "Trebuchet MS", sans-serif',
// default font family from Victory, need to be specified explicitly for some reason, otherwise text size gets miscalculated
}}
flyoutStyle={{
stroke: "#999",
fill: "white",
}}
cornerRadius={4}
flyoutPadding={{ top: 4, bottom: 4, left: 12, right: 12 }}
/>
}
radius={50}
voronoiBlacklist={
[...Array(MAX_LINES).keys()].map((i) => `line-${i}`)
// see: https://github.com/FormidableLabs/victory/issues/545
}
/>
}
scale={{
x: "time",
y: "linear",
}}
domain={{
x: [minDate, maxDate],
y: [0, domainMax],
}}
>
<VictoryAxis
tickCount={Math.min(7, differenceInDays(maxDate, minDate) + 1)}
style={{
grid: { stroke: null, strokeWidth: 0.5 },
}}
tickLabelComponent={
<VictoryLabel
dx={-30}
dy={-3}
angle={-30}
style={{ fontSize: 18, fill: "#777" }}
/>
}
scale={{ x: "time" }}
tickFormat={(t) => format(t, "yyyy-MM-dd")}
/>
<VictoryAxis
dependentAxis
style={{
grid: { stroke: "#D3D3D3", strokeWidth: 0.5 },
}}
tickLabelComponent={
<VictoryLabel dy={0} style={{ fontSize: 18, fill: "#777" }} />
}
// tickFormat specifies how ticks should be displayed
tickFormat={(x) => `${x * 100}%`}
/>
{[...Array(MAX_LINES).keys()]
.reverse() // affects svg render order, we want to render largest datasets on top of others
.filter((i) => i !== highlight)
.map((i) =>
getVictoryGroup({
data: dataSets[i],
i,
highlight: false,
})
)}
{highlight === undefined
? null
: // render highlighted series on top of everything else
getVictoryGroup({
data: dataSets[highlight],
i: highlight,
highlight: true,
})}
</VictoryChart>
<Legend
items={dataSetsNames.map((name, i) => ({ name, color: colors[i] }))}
setHighlight={setHighlight}
/>
</div>
);
};

View File

@ -0,0 +1,168 @@
import { differenceInDays, format } from "date-fns";
import {
VictoryAxis, VictoryChart, VictoryGroup, VictoryLabel, VictoryLine, VictoryScatter,
VictoryTheme, VictoryTooltip, VictoryVoronoiContainer
} from "victory";
import { chartColors, ChartData, ChartSeries, height, width } from "./utils";
// can't be replaced with React component, VictoryChart requires VictoryGroup elements to be immediate children
const getVictoryGroup = ({
data,
i,
highlight,
}: {
data: ChartSeries;
i: number;
highlight?: boolean;
}) => {
console.log(i, data, highlight, data.length);
return (
<VictoryGroup color={chartColors[i] || "darkgray"} data={data} key={i}>
<VictoryLine
name={`line-${i}`}
style={{
data: {
strokeOpacity: highlight ? 1 : 0.5,
},
}}
/>
<VictoryScatter
name={`scatter-${i}`}
size={({ active }) => (active || highlight ? 3.75 : 3)}
/>
</VictoryGroup>
);
};
export const InnerChart: React.FC<{
data: ChartData;
highlight: number | undefined;
}> = ({
data: { maxProbability, seriesList, minDate, maxDate },
highlight,
}) => {
const domainMax =
maxProbability < 0.5 ? Math.round(10 * (maxProbability + 0.05)) / 10 : 1;
const padding = {
top: 20,
bottom: 65,
left: 60,
right: 20,
};
return (
<VictoryChart
domainPadding={20}
padding={padding}
theme={VictoryTheme.material}
height={height}
width={width}
containerComponent={
<VictoryVoronoiContainer
labels={() => "Not shown"}
labelComponent={
<VictoryTooltip
constrainToVisibleArea
pointerLength={0}
dy={-12}
labelComponent={
<VictoryLabel
style={[
{
fontSize: 18,
fill: "black",
strokeWidth: 0.05,
},
{
fontSize: 18,
fill: "#777",
strokeWidth: 0.05,
},
]}
/>
}
text={({ datum }) =>
`${datum.name}: ${Math.round(datum.y * 100)}%\n${format(
datum.x,
"yyyy-MM-dd"
)}`
}
style={{
fontSize: 18, // needs to be set here and not just in labelComponent for text size calculations
fontFamily:
'"Gill Sans", "Gill Sans MT", "Ser­avek", "Trebuchet MS", sans-serif',
// default font family from Victory, need to be specified explicitly for some reason, otherwise text size gets miscalculated
}}
flyoutStyle={{
stroke: "#999",
fill: "white",
}}
cornerRadius={4}
flyoutPadding={{ top: 4, bottom: 4, left: 12, right: 12 }}
/>
}
radius={50}
voronoiBlacklist={
[...Array(seriesList.length).keys()].map((i) => `line-${i}`)
// see: https://github.com/FormidableLabs/victory/issues/545
}
/>
}
scale={{
x: "time",
y: "linear",
}}
domain={{
x: [minDate, maxDate],
y: [0, domainMax],
}}
>
<VictoryAxis
tickCount={Math.min(7, differenceInDays(maxDate, minDate) + 1)}
style={{
grid: { stroke: null, strokeWidth: 0.5 },
}}
tickLabelComponent={
<VictoryLabel
dx={-38}
dy={-5}
angle={-30}
style={{ fontSize: 18, fill: "#777" }}
/>
}
scale={{ x: "time" }}
tickFormat={(t) => format(t, "yyyy-MM-dd")}
/>
<VictoryAxis
dependentAxis
style={{
grid: { stroke: "#D3D3D3", strokeWidth: 0.5 },
}}
tickLabelComponent={
<VictoryLabel dy={0} style={{ fontSize: 18, fill: "#777" }} />
}
// tickFormat specifies how ticks should be displayed
tickFormat={(x) => `${x * 100}%`}
/>
{[...Array(seriesList.length).keys()]
.reverse() // affects svg render order, we want to render largest datasets on top of others
.filter((i) => i !== highlight)
.map((i) =>
getVictoryGroup({
data: seriesList[i],
i,
highlight: false,
})
)}
{highlight === undefined
? null
: // render highlighted series on top of everything else
getVictoryGroup({
data: seriesList[highlight],
i: highlight,
highlight: true,
})}
</VictoryChart>
);
};

View File

@ -0,0 +1,11 @@
import { height, width } from "./utils";
export const InnerChartPlaceholder: React.FC = () => {
return (
<svg
width={width}
height={height}
style={{ width: "100%", height: "100%" }}
/>
);
};

View File

@ -0,0 +1,59 @@
import dynamic from "next/dynamic";
import React, { useMemo, useState } from "react";
import { QuestionWithHistoryFragment } from "../../../fragments.generated";
import { InnerChartPlaceholder } from "./InnerChartPlaceholder";
import { buildChartData, chartColors } from "./utils";
const InnerChart = dynamic(
() => import("./InnerChart").then((mod) => mod.InnerChart),
{ ssr: false, loading: () => <InnerChartPlaceholder /> }
);
interface Props {
question: QuestionWithHistoryFragment;
}
const Legend: React.FC<{
items: { name: string; color: string }[];
setHighlight: (i: number | undefined) => void;
}> = ({ items, setHighlight }) => {
return (
<div className="space-y-2" onMouseLeave={() => setHighlight(undefined)}>
{items.map((item, i) => (
<div
className="flex items-center"
key={item.name}
onMouseOver={() => setHighlight(i)}
>
<svg className="mt-1 shrink-0" height="10" width="16">
<circle cx="4" cy="4" r="4" fill={item.color} />
</svg>
<span className="text-xs sm:text-sm sm:whitespace-nowrap sm:text-ellipsis sm:overflow-hidden sm:max-w-160">
{item.name}
</span>
</div>
))}
</div>
);
};
export const HistoryChart: React.FC<Props> = ({ question }) => {
// maybe use context instead?
const [highlight, setHighlight] = useState<number | undefined>(undefined);
const data = useMemo(() => buildChartData(question), [question]);
return (
<div className="flex items-center flex-col sm:flex-row">
<InnerChart data={data} highlight={highlight} />
<Legend
items={data.seriesNames.map((name, i) => ({
name,
color: chartColors[i],
}))}
setHighlight={setHighlight}
/>
</div>
);
};

View File

@ -0,0 +1,113 @@
import { addDays, startOfDay, startOfToday, startOfTomorrow } from "date-fns";
import { QuestionWithHistoryFragment } from "../../../fragments.generated";
export type ChartSeries = { x: Date; y: number; name: string }[];
export const MAX_LINES = 5;
// number of colors should match MAX_LINES
// colors are taken from tailwind, https://tailwindcss.com/docs/customizing-colors
export const chartColors = [
"#0284C7", // sky-600
"#DC2626", // red-600
"#15803D", // green-700
"#7E22CE", // purple-700
"#F59E0B", // amber-500
];
const goldenRatio = (1 + Math.sqrt(5)) / 2;
// used both for chart and for ssr placeholder
export const width = 750;
export const height = width / goldenRatio;
export type ChartData = {
seriesList: ChartSeries[];
seriesNames: string[];
maxProbability: number;
minDate: Date;
maxDate: Date;
};
export const buildChartData = (
question: QuestionWithHistoryFragment
): ChartData => {
let seriesNames = question.options
.sort((a, b) => {
if (a.probability > b.probability) {
return -1;
} else if (a.probability < b.probability) {
return 1;
}
return a.name < b.name ? -1 : 1; // needed for stable sorting - otherwise it's possible to get order mismatch in SSR vs client-side
})
.map((o) => o.name)
.slice(0, MAX_LINES);
const isBinary =
(seriesNames[0] === "Yes" && seriesNames[1] === "No") ||
(seriesNames[0] === "No" && seriesNames[1] === "Yes");
if (isBinary) {
seriesNames = ["Yes"];
}
const nameToIndex = Object.fromEntries(
seriesNames.map((name, i) => [name, i])
);
let seriesList: ChartSeries[] = [...Array(seriesNames.length)].map((x) => []);
const sortedHistory = question.history.sort((a, b) =>
a.timestamp < b.timestamp ? -1 : 1
);
{
let previousDate = -Infinity;
for (const item of sortedHistory) {
if (item.timestamp - previousDate < 12 * 60 * 60) {
continue;
}
const date = new Date(item.timestamp * 1000);
for (const option of item.options) {
const idx = nameToIndex[option.name];
if (idx === undefined) {
continue;
}
const result = {
x: date,
y: option.probability,
name: option.name,
};
seriesList[idx].push(result);
}
previousDate = item.timestamp;
}
}
let maxProbability = 0;
for (const dataSet of seriesList) {
for (const item of dataSet) {
maxProbability = Math.max(maxProbability, item.y);
}
}
const minDate = sortedHistory.length
? startOfDay(new Date(sortedHistory[0].timestamp * 1000))
: startOfToday();
const maxDate = sortedHistory.length
? addDays(
startOfDay(
new Date(sortedHistory[sortedHistory.length - 1].timestamp * 1000)
),
1
)
: startOfTomorrow();
return {
seriesList,
seriesNames,
maxProbability,
minDate,
maxDate,
};
};