feat: legend improvements
This commit is contained in:
parent
43383237aa
commit
290cb48960
|
@ -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,6 +137,7 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
: startOfTomorrow();
|
: startOfTomorrow();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex items-center flex-col sm:flex-row">
|
||||||
<VictoryChart
|
<VictoryChart
|
||||||
domainPadding={20}
|
domainPadding={20}
|
||||||
padding={padding}
|
padding={padding}
|
||||||
|
@ -154,12 +156,12 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
<VictoryLabel
|
<VictoryLabel
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
fill: "black",
|
fill: "black",
|
||||||
strokeWidth: 0.05,
|
strokeWidth: 0.05,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
fill: "#777",
|
fill: "#777",
|
||||||
strokeWidth: 0.05,
|
strokeWidth: 0.05,
|
||||||
},
|
},
|
||||||
|
@ -173,7 +175,7 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
)}`
|
)}`
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16, // needs to be set here and not just in labelComponent for text size calculations
|
fontSize: 18, // needs to be set here and not just in labelComponent for text size calculations
|
||||||
fontFamily:
|
fontFamily:
|
||||||
'"Gill Sans", "Gill Sans MT", "Seravek", "Trebuchet MS", sans-serif',
|
'"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
|
// default font family from Victory, need to be specified explicitly for some reason, otherwise text size gets miscalculated
|
||||||
|
@ -202,18 +204,9 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
y: [0, domainMax],
|
y: [0, domainMax],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VictoryLegend
|
{dataSets.map((dataset, i) =>
|
||||||
x={width - labelLegendStart - letterLength * longestNameLength}
|
getVictoryGroup({ data: dataset, i, highlight: i === highlight })
|
||||||
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
|
<VictoryAxis
|
||||||
tickCount={Math.min(7, differenceInDays(maxDate, minDate) + 1)}
|
tickCount={Math.min(7, differenceInDays(maxDate, minDate) + 1)}
|
||||||
style={{
|
style={{
|
||||||
|
@ -224,7 +217,7 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
dx={-30}
|
dx={-30}
|
||||||
dy={-3}
|
dy={-3}
|
||||||
angle={-30}
|
angle={-30}
|
||||||
style={{ fontSize: 15, fill: "#777" }}
|
style={{ fontSize: 18, fill: "#777" }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
scale={{ x: "time" }}
|
scale={{ x: "time" }}
|
||||||
|
@ -236,11 +229,16 @@ export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
grid: { stroke: "#D3D3D3", strokeWidth: 0.5 },
|
grid: { stroke: "#D3D3D3", strokeWidth: 0.5 },
|
||||||
}}
|
}}
|
||||||
tickLabelComponent={
|
tickLabelComponent={
|
||||||
<VictoryLabel dy={0} style={{ fontSize: 15, fill: "#777" }} />
|
<VictoryLabel dy={0} style={{ fontSize: 18, fill: "#777" }} />
|
||||||
}
|
}
|
||||||
// tickFormat specifies how ticks should be displayed
|
// tickFormat specifies how ticks should be displayed
|
||||||
tickFormat={(x) => `${x * 100}%`}
|
tickFormat={(x) => `${x * 100}%`}
|
||||||
/>
|
/>
|
||||||
</VictoryChart>
|
</VictoryChart>
|
||||||
|
<Legend
|
||||||
|
items={dataSetsNames.map((name, i) => ({ name, color: colors[i] }))}
|
||||||
|
setHighlight={setHighlight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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("] (", "](")
|
||||||
|
|
|
@ -6,6 +6,9 @@ module.exports = {
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
quri: "url('/icons/logo.svg')",
|
quri: "url('/icons/logo.svg')",
|
||||||
},
|
},
|
||||||
|
maxWidth: {
|
||||||
|
160: "160px",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user