diff --git a/src/web/questions/components/HistoryChart.tsx b/src/web/questions/components/HistoryChart.tsx deleted file mode 100644 index d9d67cc..0000000 --- a/src/web/questions/components/HistoryChart.tsx +++ /dev/null @@ -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 ( - - - (active || highlight ? 3.75 : 3)} - /> - - ); -}; - -const Legend: React.FC<{ - items: { name: string; color: string }[]; - setHighlight: (i: number | undefined) => void; -}> = ({ items, setHighlight }) => { - return ( -
setHighlight(undefined)}> - {items.map((item, i) => ( -
setHighlight(i)} - > - - - - - {item.name} - -
- ))} -
- ); -}; - -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 = ({ question }) => { - const [highlight, setHighlight] = useState(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 ( -
- "Not shown"} - labelComponent={ - - } - 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], - }} - > - - } - scale={{ x: "time" }} - tickFormat={(t) => format(t, "yyyy-MM-dd")} - /> - - } - // 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, - })} - - ({ name, color: colors[i] }))} - setHighlight={setHighlight} - /> -
- ); -}; diff --git a/src/web/questions/components/HistoryChart/InnerChart.tsx b/src/web/questions/components/HistoryChart/InnerChart.tsx new file mode 100644 index 0000000..0b7ac73 --- /dev/null +++ b/src/web/questions/components/HistoryChart/InnerChart.tsx @@ -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 ( + + + (active || highlight ? 3.75 : 3)} + /> + + ); +}; + +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 ( + "Not shown"} + labelComponent={ + + } + 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], + }} + > + + } + scale={{ x: "time" }} + tickFormat={(t) => format(t, "yyyy-MM-dd")} + /> + + } + // 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, + })} + + ); +}; diff --git a/src/web/questions/components/HistoryChart/InnerChartPlaceholder.tsx b/src/web/questions/components/HistoryChart/InnerChartPlaceholder.tsx new file mode 100644 index 0000000..c72d3a0 --- /dev/null +++ b/src/web/questions/components/HistoryChart/InnerChartPlaceholder.tsx @@ -0,0 +1,11 @@ +import { height, width } from "./utils"; + +export const InnerChartPlaceholder: React.FC = () => { + return ( + + ); +}; diff --git a/src/web/questions/components/HistoryChart/index.tsx b/src/web/questions/components/HistoryChart/index.tsx new file mode 100644 index 0000000..3c2365b --- /dev/null +++ b/src/web/questions/components/HistoryChart/index.tsx @@ -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: () => } +); + +interface Props { + question: QuestionWithHistoryFragment; +} + +const Legend: React.FC<{ + items: { name: string; color: string }[]; + setHighlight: (i: number | undefined) => void; +}> = ({ items, setHighlight }) => { + return ( +
setHighlight(undefined)}> + {items.map((item, i) => ( +
setHighlight(i)} + > + + + + + {item.name} + +
+ ))} +
+ ); +}; + +export const HistoryChart: React.FC = ({ question }) => { + // maybe use context instead? + const [highlight, setHighlight] = useState(undefined); + + const data = useMemo(() => buildChartData(question), [question]); + + return ( +
+ + ({ + name, + color: chartColors[i], + }))} + setHighlight={setHighlight} + /> +
+ ); +}; diff --git a/src/web/questions/components/HistoryChart/utils.ts b/src/web/questions/components/HistoryChart/utils.ts new file mode 100644 index 0000000..886bdfb --- /dev/null +++ b/src/web/questions/components/HistoryChart/utils.ts @@ -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, + }; +};