feat: lazy-load charts, avoid ssr
This commit is contained in:
parent
da66ea05f8
commit
8c5ed35c0f
|
@ -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", "Seravek", "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>
|
|
||||||
);
|
|
||||||
};
|
|
168
src/web/questions/components/HistoryChart/InnerChart.tsx
Normal file
168
src/web/questions/components/HistoryChart/InnerChart.tsx
Normal 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", "Seravek", "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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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%" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
59
src/web/questions/components/HistoryChart/index.tsx
Normal file
59
src/web/questions/components/HistoryChart/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
113
src/web/questions/components/HistoryChart/utils.ts
Normal file
113
src/web/questions/components/HistoryChart/utils.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user