feat: legend improvements

This commit is contained in:
Vyacheslav Matyukhin 2022-05-06 23:51:26 +04:00
parent 43383237aa
commit 290cb48960
No known key found for this signature in database
GPG Key ID: 3D2A774C5489F96C
3 changed files with 145 additions and 143 deletions

View File

@ -1,10 +1,10 @@
import { import {
addDays, differenceInDays, format, startOfDay, startOfToday, startOfTomorrow addDays, differenceInDays, format, startOfDay, startOfToday, startOfTomorrow
} from "date-fns"; } from "date-fns";
import React from "react"; import React, { useState } from "react";
import { import {
VictoryAxis, VictoryChart, VictoryGroup, VictoryLabel, VictoryLegend, VictoryLine, VictoryAxis, VictoryChart, VictoryGroup, VictoryLabel, VictoryLine, VictoryScatter,
VictoryScatter, VictoryTheme, VictoryTooltip, VictoryVoronoiContainer VictoryTheme, VictoryTooltip, VictoryVoronoiContainer
} from "victory"; } from "victory";
import { QuestionWithHistoryFragment } from "../../fragments.generated"; import { QuestionWithHistoryFragment } from "../../fragments.generated";
@ -13,37 +13,25 @@ interface Props {
question: QuestionWithHistoryFragment; question: QuestionWithHistoryFragment;
} }
const formatOptionName = (name: string) => {
return name.length > 20 ? name.slice(0, 17) + "..." : name;
};
const getLength = (str: string): number => {
// TODO - measure with temporary DOM element instead?
const capitalLetterLengthMultiplier = 1.25;
const smallLetterMultiplier = 0.8;
const numUpper = (str.match(/[A-Z]/g) || []).length;
const numSmallLetters = (str.match(/[fijlrt]/g) || []).length;
const numSpaces = (str.match(/[\s]/g) || []).length;
const length =
str.length +
-numUpper -
numSmallLetters +
numUpper * capitalLetterLengthMultiplier +
(numSmallLetters + numSpaces) * smallLetterMultiplier;
return length;
};
type DataSet = { x: Date; y: number; name: string }[]; type DataSet = { x: Date; y: number; name: string }[];
const colors = ["dodgerblue", "crimson", "seagreen", "darkviolet", "turquoise"]; const colors = ["dodgerblue", "crimson", "seagreen", "darkviolet", "turquoise"];
// 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 = ({ data, i }: { data: DataSet; i: number }) => { const getVictoryGroup = ({
data,
i,
highlight,
}: {
data: DataSet;
i: number;
highlight?: boolean;
}) => {
return ( return (
<VictoryGroup color={colors[i] || "darkgray"} data={data} key={i}> <VictoryGroup color={colors[i] || "darkgray"} data={data} key={i}>
<VictoryScatter <VictoryScatter
name={`scatter-${i}`} name={`scatter-${i}`}
size={({ active }) => (active ? 3.75 : 3)} size={({ active }) => (active || highlight ? 3.75 : 3)}
/> />
<VictoryLine name={`line-${i}`} /> <VictoryLine name={`line-${i}`} />
@ -51,7 +39,33 @@ const getVictoryGroup = ({ data, i }: { data: DataSet; i: number }) => {
); );
}; };
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 }) => { export const HistoryChart: React.FC<Props> = ({ question }) => {
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);
@ -66,7 +80,6 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
let dataSets: DataSet[] = []; let dataSets: DataSet[] = [];
let maxProbability = 0; let maxProbability = 0;
let longestNameLength = 0;
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
@ -75,7 +88,7 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
for (const name of dataSetsNames) { for (const name of dataSetsNames) {
let newDataset: DataSet = []; let newDataset: DataSet = [];
let previousDate = -Infinity; let previousDate = -Infinity;
for (let item of sortedHistory) { for (const item of sortedHistory) {
const relevantItemsArray = item.options.filter((x) => x.name === name); const relevantItemsArray = item.options.filter((x) => x.name === name);
const date = new Date(item.timestamp * 1000); const date = new Date(item.timestamp * 1000);
if ( if (
@ -92,9 +105,6 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
relevantItem.probability > maxProbability relevantItem.probability > maxProbability
? relevantItem.probability ? relevantItem.probability
: maxProbability; : maxProbability;
let length = getLength(formatOptionName(relevantItem.name));
longestNameLength =
length > longestNameLength ? length : longestNameLength;
newDataset.push(result); newDataset.push(result);
previousDate = item.timestamp; previousDate = item.timestamp;
} }
@ -102,12 +112,8 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
dataSets.push(newDataset); dataSets.push(newDataset);
} }
const letterLength = 7;
const labelLegendStart = 45;
const domainMax = const domainMax =
maxProbability < 0.5 ? Math.round(10 * (maxProbability + 0.05)) / 10 : 1; maxProbability < 0.5 ? Math.round(10 * (maxProbability + 0.05)) / 10 : 1;
const dataSetsLength = dataSets.length;
const goldenRatio = (1 + Math.sqrt(5)) / 2; const goldenRatio = (1 + Math.sqrt(5)) / 2;
const width = 750; const width = 750;
const height = width / goldenRatio; const height = width / goldenRatio;
@ -115,14 +121,9 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
top: 20, top: 20,
bottom: 60, bottom: 60,
left: 60, left: 60,
right: labelLegendStart + letterLength * longestNameLength, right: 20,
}; };
const legendData = Array.from(Array(dataSetsLength).keys()).map((i) => ({
name: formatOptionName(dataSetsNames[i]),
symbol: { fill: colors[i] },
}));
const minDate = sortedHistory.length const minDate = sortedHistory.length
? startOfDay(new Date(sortedHistory[0].timestamp * 1000)) ? startOfDay(new Date(sortedHistory[0].timestamp * 1000))
: startOfToday(); : startOfToday();
@ -136,111 +137,108 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
: startOfTomorrow(); : startOfTomorrow();
return ( return (
<VictoryChart <div className="flex items-center flex-col sm:flex-row">
domainPadding={20} <VictoryChart
padding={padding} domainPadding={20}
theme={VictoryTheme.material} padding={padding}
height={height} theme={VictoryTheme.material}
width={width} height={height}
containerComponent={ width={width}
<VictoryVoronoiContainer containerComponent={
labels={() => "Not shown"} <VictoryVoronoiContainer
labelComponent={ labels={() => "Not shown"}
<VictoryTooltip labelComponent={
constrainToVisibleArea <VictoryTooltip
pointerLength={0} constrainToVisibleArea
dy={-12} pointerLength={0}
labelComponent={ dy={-12}
<VictoryLabel labelComponent={
style={[ <VictoryLabel
{ style={[
fontSize: 16, {
fill: "black", fontSize: 18,
strokeWidth: 0.05, fill: "black",
}, strokeWidth: 0.05,
{ },
fontSize: 16, {
fill: "#777", fontSize: 18,
strokeWidth: 0.05, fill: "#777",
}, strokeWidth: 0.05,
]} },
/> ]}
} />
text={({ datum }) => }
`${datum.name}: ${Math.round(datum.y * 100)}%\n${format( text={({ datum }) =>
datum.x, `${datum.name}: ${Math.round(datum.y * 100)}%\n${format(
"yyyy-MM-dd" datum.x,
)}` "yyyy-MM-dd"
} )}`
style={{ }
fontSize: 16, // needs to be set here and not just in labelComponent for text size calculations style={{
fontFamily: fontSize: 18, // needs to be set here and not just in labelComponent for text size calculations
'"Gill Sans", "Gill Sans MT", "Ser­avek", "Trebuchet MS", sans-serif', fontFamily:
// default font family from Victory, need to be specified explicitly for some reason, otherwise text size gets miscalculated '"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", flyoutStyle={{
fill: "white", stroke: "#999",
}} fill: "white",
cornerRadius={4} }}
flyoutPadding={{ top: 4, bottom: 4, left: 12, right: 12 }} cornerRadius={4}
/> flyoutPadding={{ top: 4, bottom: 4, left: 12, right: 12 }}
} />
radius={50} }
voronoiBlacklist={ radius={50}
[...Array(5).keys()].map((i) => `line-${i}`) voronoiBlacklist={
// see: https://github.com/FormidableLabs/victory/issues/545 [...Array(5).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],
}}
>
<VictoryLegend
x={width - labelLegendStart - letterLength * longestNameLength}
y={height / 2 - 18 - (dataSetsLength - 1) * 13}
orientation="vertical"
gutter={20}
style={{ border: { stroke: "white" }, labels: { fontSize: 15 } }}
data={legendData}
/>
{dataSets
.slice(0, 5)
.map((dataset, i) => getVictoryGroup({ data: dataset, i }))}
<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: 15, fill: "#777" }}
/> />
} }
scale={{ x: "time" }} scale={{
tickFormat={(t) => format(t, "yyyy-MM-dd")} x: "time",
/> y: "linear",
<VictoryAxis
dependentAxis
style={{
grid: { stroke: "#D3D3D3", strokeWidth: 0.5 },
}} }}
tickLabelComponent={ domain={{
<VictoryLabel dy={0} style={{ fontSize: 15, fill: "#777" }} /> x: [minDate, maxDate],
} y: [0, domainMax],
// tickFormat specifies how ticks should be displayed }}
tickFormat={(x) => `${x * 100}%`} >
{dataSets.map((dataset, i) =>
getVictoryGroup({ data: dataset, i, highlight: i === highlight })
)}
<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}%`}
/>
</VictoryChart>
<Legend
items={dataSetsNames.map((name, i) => ({ name, color: colors[i] }))}
setHighlight={setHighlight}
/> />
</VictoryChart> </div>
); );
}; };

View File

@ -12,7 +12,8 @@ export const getBasePath = () => {
}; };
export const cleanText = (text: string): string => { export const cleanText = (text: string): string => {
// Note: should no longer be necessary // Note: should no longer be necessary?
// Still needed for e.g. /questions/rootclaim-what-caused-the-disappearance-of-malaysia-airlines-flight-370
let textString = !!text ? text : ""; let textString = !!text ? text : "";
textString = textString textString = textString
.replaceAll("] (", "](") .replaceAll("] (", "](")

View File

@ -6,6 +6,9 @@ module.exports = {
backgroundImage: { backgroundImage: {
quri: "url('/icons/logo.svg')", quri: "url('/icons/logo.svg')",
}, },
maxWidth: {
160: "160px",
},
}, },
}, },
variants: { variants: {