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,
- })}
-
-
- );
-};
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 (
+
+
+
+ );
+};
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,
+ };
+};