feat: highlight lines, refactorings
This commit is contained in:
parent
290cb48960
commit
cf8d79b8e4
|
@ -1,7 +1,7 @@
|
||||||
import {
|
import {
|
||||||
addDays, differenceInDays, format, startOfDay, startOfToday, startOfTomorrow
|
addDays, differenceInDays, format, startOfDay, startOfToday, startOfTomorrow
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import React, { useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
VictoryAxis, VictoryChart, VictoryGroup, VictoryLabel, VictoryLine, VictoryScatter,
|
VictoryAxis, VictoryChart, VictoryGroup, VictoryLabel, VictoryLine, VictoryScatter,
|
||||||
VictoryTheme, VictoryTooltip, VictoryVoronoiContainer
|
VictoryTheme, VictoryTooltip, VictoryVoronoiContainer
|
||||||
|
@ -15,7 +15,17 @@ interface Props {
|
||||||
|
|
||||||
type DataSet = { x: Date; y: number; name: string }[];
|
type DataSet = { x: Date; y: number; name: string }[];
|
||||||
|
|
||||||
const colors = ["dodgerblue", "crimson", "seagreen", "darkviolet", "turquoise"];
|
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
|
// can't be replaced with React component, VictoryChart requires VictoryGroup elements to be immediate children
|
||||||
const getVictoryGroup = ({
|
const getVictoryGroup = ({
|
||||||
|
@ -29,12 +39,18 @@ const getVictoryGroup = ({
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<VictoryGroup color={colors[i] || "darkgray"} data={data} key={i}>
|
<VictoryGroup color={colors[i] || "darkgray"} data={data} key={i}>
|
||||||
|
<VictoryLine
|
||||||
|
name={`line-${i}`}
|
||||||
|
style={{
|
||||||
|
data: {
|
||||||
|
strokeOpacity: highlight ? 1 : 0.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<VictoryScatter
|
<VictoryScatter
|
||||||
name={`scatter-${i}`}
|
name={`scatter-${i}`}
|
||||||
size={({ active }) => (active || highlight ? 3.75 : 3)}
|
size={({ active }) => (active || highlight ? 3.75 : 3)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VictoryLine name={`line-${i}`} />
|
|
||||||
</VictoryGroup>
|
</VictoryGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -63,13 +79,11 @@ const Legend: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HistoryChart: React.FC<Props> = ({ question }) => {
|
const buildDataSets = (question: QuestionWithHistoryFragment) => {
|
||||||
const [highlight, setHighlight] = useState<number | undefined>(undefined);
|
|
||||||
|
|
||||||
let dataSetsNames = question.options
|
let dataSetsNames = question.options
|
||||||
.sort((a, b) => (a.probability > b.probability ? -1 : 1))
|
.sort((a, b) => (a.probability > b.probability ? -1 : 1))
|
||||||
.map((o) => o.name);
|
.map((o) => o.name)
|
||||||
dataSetsNames = [...new Set(dataSetsNames)].slice(0, 5); // take the first 5
|
.slice(0, MAX_LINES);
|
||||||
|
|
||||||
const isBinary =
|
const isBinary =
|
||||||
(dataSetsNames[0] === "Yes" && dataSetsNames[1] === "No") ||
|
(dataSetsNames[0] === "Yes" && dataSetsNames[1] === "No") ||
|
||||||
|
@ -78,51 +92,45 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
dataSetsNames = ["Yes"];
|
dataSetsNames = ["Yes"];
|
||||||
}
|
}
|
||||||
|
|
||||||
let dataSets: DataSet[] = [];
|
const nameToIndex = Object.fromEntries(
|
||||||
let maxProbability = 0;
|
dataSetsNames.map((name, i) => [name, i])
|
||||||
|
);
|
||||||
|
let dataSets: DataSet[] = [...Array(dataSetsNames.length)].map((x) => []);
|
||||||
|
|
||||||
const sortedHistory = question.history.sort((a, b) =>
|
const sortedHistory = question.history.sort((a, b) =>
|
||||||
a.timestamp < b.timestamp ? -1 : 1
|
a.timestamp < b.timestamp ? -1 : 1
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const name of dataSetsNames) {
|
{
|
||||||
let newDataset: DataSet = [];
|
|
||||||
let previousDate = -Infinity;
|
let previousDate = -Infinity;
|
||||||
for (const item of sortedHistory) {
|
for (const item of sortedHistory) {
|
||||||
const relevantItemsArray = item.options.filter((x) => x.name === name);
|
if (item.timestamp - previousDate < 12 * 60 * 60) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const date = new Date(item.timestamp * 1000);
|
const date = new Date(item.timestamp * 1000);
|
||||||
if (
|
|
||||||
relevantItemsArray.length === 1 &&
|
for (const option of item.options) {
|
||||||
item.timestamp - previousDate > 12 * 60 * 60
|
const idx = nameToIndex[option.name];
|
||||||
) {
|
if (idx === undefined) {
|
||||||
let relevantItem = relevantItemsArray[0];
|
continue;
|
||||||
|
}
|
||||||
const result = {
|
const result = {
|
||||||
x: date,
|
x: date,
|
||||||
y: relevantItem.probability,
|
y: option.probability,
|
||||||
name: relevantItem.name,
|
name: option.name,
|
||||||
};
|
};
|
||||||
maxProbability =
|
dataSets[idx].push(result);
|
||||||
relevantItem.probability > maxProbability
|
|
||||||
? relevantItem.probability
|
|
||||||
: maxProbability;
|
|
||||||
newDataset.push(result);
|
|
||||||
previousDate = item.timestamp;
|
|
||||||
}
|
}
|
||||||
|
previousDate = item.timestamp;
|
||||||
}
|
}
|
||||||
dataSets.push(newDataset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const domainMax =
|
let maxProbability = 0;
|
||||||
maxProbability < 0.5 ? Math.round(10 * (maxProbability + 0.05)) / 10 : 1;
|
for (const dataSet of dataSets) {
|
||||||
const goldenRatio = (1 + Math.sqrt(5)) / 2;
|
for (const item of dataSet) {
|
||||||
const width = 750;
|
maxProbability = Math.max(maxProbability, item.y);
|
||||||
const height = width / goldenRatio;
|
}
|
||||||
const padding = {
|
}
|
||||||
top: 20,
|
|
||||||
bottom: 60,
|
|
||||||
left: 60,
|
|
||||||
right: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
const minDate = sortedHistory.length
|
const minDate = sortedHistory.length
|
||||||
? startOfDay(new Date(sortedHistory[0].timestamp * 1000))
|
? startOfDay(new Date(sortedHistory[0].timestamp * 1000))
|
||||||
|
@ -136,6 +144,36 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
)
|
)
|
||||||
: startOfTomorrow();
|
: 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 (
|
return (
|
||||||
<div className="flex items-center flex-col sm:flex-row">
|
<div className="flex items-center flex-col sm:flex-row">
|
||||||
<VictoryChart
|
<VictoryChart
|
||||||
|
@ -190,7 +228,7 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
}
|
}
|
||||||
radius={50}
|
radius={50}
|
||||||
voronoiBlacklist={
|
voronoiBlacklist={
|
||||||
[...Array(5).keys()].map((i) => `line-${i}`)
|
[...Array(MAX_LINES).keys()].map((i) => `line-${i}`)
|
||||||
// see: https://github.com/FormidableLabs/victory/issues/545
|
// see: https://github.com/FormidableLabs/victory/issues/545
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -204,9 +242,6 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
y: [0, domainMax],
|
y: [0, domainMax],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dataSets.map((dataset, i) =>
|
|
||||||
getVictoryGroup({ data: dataset, i, highlight: i === highlight })
|
|
||||||
)}
|
|
||||||
<VictoryAxis
|
<VictoryAxis
|
||||||
tickCount={Math.min(7, differenceInDays(maxDate, minDate) + 1)}
|
tickCount={Math.min(7, differenceInDays(maxDate, minDate) + 1)}
|
||||||
style={{
|
style={{
|
||||||
|
@ -234,6 +269,13 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
// tickFormat specifies how ticks should be displayed
|
// tickFormat specifies how ticks should be displayed
|
||||||
tickFormat={(x) => `${x * 100}%`}
|
tickFormat={(x) => `${x * 100}%`}
|
||||||
/>
|
/>
|
||||||
|
{
|
||||||
|
dataSets
|
||||||
|
.map((dataSet, i) =>
|
||||||
|
getVictoryGroup({ data: dataSet, i, highlight: i === highlight })
|
||||||
|
)
|
||||||
|
.reverse() // affects svg render order, we want to render largest datasets on top of others
|
||||||
|
}
|
||||||
</VictoryChart>
|
</VictoryChart>
|
||||||
<Legend
|
<Legend
|
||||||
items={dataSetsNames.map((name, i) => ({ name, color: colors[i] }))}
|
items={dataSetsNames.map((name, i) => ({ name, color: colors[i] }))}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user