Merge pull request #74 from quantified-uncertainty/questions-page-2

Question pages and charts
This commit is contained in:
Vyacheslav Matyukhin 2022-05-09 17:31:04 +03:00 committed by GitHub
commit 44fbd0cd2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 2562 additions and 769 deletions

6
.eslintrc Normal file
View File

@ -0,0 +1,6 @@
{
"extends": ["next", "prettier"],
"rules": {
"next/no-document-import-in-page": "off"
}
}

1131
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,7 @@
"dbshell": ". .env && psql $DIGITALOCEAN_POSTGRES"
},
"dependencies": {
"@floating-ui/react-dom": "^0.7.0",
"@graphql-yoga/node": "^2.1.0",
"@pothos/core": "^3.5.1",
"@pothos/plugin-prisma": "^3.4.0",
@ -34,15 +35,18 @@
"@prisma/client": "^3.11.1",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.1",
"@types/dom-to-image": "^2.6.4",
"@types/jsdom": "^16.2.14",
"@types/nprogress": "^0.2.0",
"@types/react": "^17.0.39",
"@types/react-copy-to-clipboard": "^5.0.2",
"airtable": "^0.11.1",
"algoliasearch": "^4.10.3",
"autoprefixer": "^10.1.0",
"axios": "^0.25.0",
"chroma-js": "^2.4.2",
"critters": "^0.0.16",
"date-fns": "^2.28.0",
"dom-to-image": "^2.6.0",
"dotenv": "^16.0.0",
"fetch": "^1.1.0",
@ -89,7 +93,8 @@
"ts-node": "^10.7.0",
"tunnel": "^0.0.6",
"urql": "^2.2.0",
"urql-custom-scalars-exchange": "^0.1.5"
"urql-custom-scalars-exchange": "^0.1.5",
"victory": "^36.3.2"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.6.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

View File

@ -19,11 +19,14 @@ export async function getFrontpage(): Promise<Question[]> {
export async function rebuildFrontpage() {
await measureTime(async () => {
const rows = await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM questions
SELECT questions.id FROM questions, history
WHERE
(qualityindicators->>'stars')::int >= 3
AND description != ''
AND JSONB_ARRAY_LENGTH(options) > 0
questions.id = history.id
AND (questions.qualityindicators->>'stars')::int >= 3
AND questions.description != ''
AND JSONB_ARRAY_LENGTH(questions.options) > 0
GROUP BY questions.id
HAVING COUNT(DISTINCT history.timestamp) >= 7
ORDER BY RANDOM() LIMIT 50
`;

View File

@ -0,0 +1,103 @@
import axios from "axios";
import { parseISO } from "date-fns";
/* Imports */
import { Question } from "@prisma/client";
import { AlgoliaQuestion } from "../../backend/utils/algolia";
import { prisma } from "../database/prisma";
import { Platform } from "./";
/* Definitions */
const searchEndpoint =
"https://m629r9ugsg-dsn.algolia.net/1/indexes/Space_production/query?x-algolia-agent=Algolia%20for%20vanilla%20JavaScript%203.32.1&x-algolia-application-id=M629R9UGSG&x-algolia-api-key=4e893740a2bd467a96c8bfcf95b2809c";
const apiEndpoint = "https://guesstimate.herokuapp.com";
/* Body */
const modelToQuestion = (model: any): Question => {
const { description } = model;
// const description = model.description
// ? model.description.replace(/\n/g, " ").replace(/ /g, " ")
// : "";
const stars = description.length > 250 ? 2 : 1;
const timestamp = parseISO(model.created_at);
const q: Question = {
id: `guesstimate-${model.id}`,
title: model.name,
url: `https://www.getguesstimate.com/models/${model.id}`,
timestamp,
platform: "guesstimate",
description,
options: [],
qualityindicators: {
stars,
numforecasts: 1,
numforecasters: 1,
},
extra: {
visualization: model.big_screenshot,
},
// ranking: 10 * (index + 1) - 0.5, //(model._rankingInfo - 1*index)// hack
};
return q;
};
async function search(query: string): Promise<AlgoliaQuestion[]> {
const response = await axios({
url: searchEndpoint,
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
data: `{\"params\":\"query=${query.replace(
/ /g,
"%20"
)}&hitsPerPage=20&page=0&getRankingInfo=true\"}`,
method: "POST",
});
const models: any[] = response.data.hits;
const mappedModels: AlgoliaQuestion[] = models.map((model) => {
const q = modelToQuestion(model);
return {
...q,
timestamp: String(q.timestamp),
};
});
// filter for duplicates. Surprisingly common.
let uniqueTitles = [];
let uniqueModels: AlgoliaQuestion[] = [];
for (let model of mappedModels) {
if (!uniqueTitles.includes(model.title) && !model.title.includes("copy")) {
uniqueModels.push(model);
uniqueTitles.push(model.title);
}
}
return uniqueModels;
}
const fetchQuestion = async (id: number): Promise<Question> => {
const response = await axios({ url: `${apiEndpoint}/spaces/${id}` });
let q = modelToQuestion(response.data);
q = await prisma.question.upsert({
where: { id: q.id },
create: q,
update: q,
});
return q;
};
export const guesstimate: Platform & {
search: typeof search;
fetchQuestion: typeof fetchQuestion;
} = {
name: "guesstimate",
label: "Guesstimate",
color: "#223900",
search,
fetchQuestion,
};

View File

@ -7,6 +7,7 @@ import { foretold } from "./foretold";
import { givewellopenphil } from "./givewellopenphil";
import { goodjudgment } from "./goodjudgment";
import { goodjudgmentopen } from "./goodjudgmentopen";
import { guesstimate } from "./guesstimate";
import { infer } from "./infer";
import { kalshi } from "./kalshi";
import { manifold } from "./manifold";
@ -72,6 +73,7 @@ export const platforms: Platform[] = [
givewellopenphil,
goodjudgment,
goodjudgmentopen,
guesstimate,
infer,
kalshi,
manifold,
@ -161,21 +163,12 @@ export interface PlatformConfig {
}
// get frontend-safe version of platforms data
export const getPlatformsConfig = (options: {
withGuesstimate: boolean;
}): PlatformConfig[] => {
export const getPlatformsConfig = (): PlatformConfig[] => {
const platformsConfig = platforms.map((platform) => ({
name: platform.name,
label: platform.label,
color: platform.color,
}));
if (options.withGuesstimate) {
platformsConfig.push({
name: "guesstimate",
label: "Guesstimate",
color: "223900",
});
}
return platformsConfig;
};

View File

@ -2,6 +2,7 @@ import { History, Question } from "@prisma/client";
import { prisma } from "../../backend/database/prisma";
import { QualityIndicators } from "../../backend/platforms";
import { guesstimate } from "../../backend/platforms/guesstimate";
import { builder } from "../builder";
import { PlatformObj } from "./platforms";
@ -113,7 +114,13 @@ export const QuestionObj = builder.prismaObject("Question", {
resolve: (parent) => (parent.extra as any)?.visualization, // used for guesstimate only, see searchGuesstimate.ts
nullable: true,
}),
history: t.relation("history", {}),
history: t.relation("history", {
query: () => ({
orderBy: {
timestamp: "asc",
},
}),
}),
}),
});
@ -138,6 +145,13 @@ builder.queryField("question", (t) =>
id: t.arg({ type: "ID", required: true }),
},
resolve: async (parent, args) => {
const parts = String(args.id).split("-");
const [platform, id] = [parts[0], parts.slice(1).join("-")];
if (platform === "guesstimate") {
const q = await guesstimate.fetchQuestion(Number(id));
console.log(q);
return q;
}
return await prisma.question.findUnique({
where: {
id: String(args.id),

View File

@ -1,5 +1,5 @@
import { guesstimate } from "../../backend/platforms/guesstimate";
import { AlgoliaQuestion } from "../../backend/utils/algolia";
import searchGuesstimate from "../../web/worker/searchGuesstimate";
import searchWithAlgolia from "../../web/worker/searchWithAlgolia";
import { builder } from "../builder";
import { QuestionObj } from "./questions";
@ -54,7 +54,7 @@ builder.queryField("searchQuestions", (t) =>
const [responsesNotGuesstimate, responsesGuesstimate] =
await Promise.all([
unawaitedAlgoliaResponse,
searchGuesstimate(query),
guesstimate.search(query),
]); // faster than two separate requests
results = [...responsesNotGuesstimate, ...responsesGuesstimate];
} else {

View File

@ -4,7 +4,6 @@ import { NextRequest, NextResponse } from "next/server";
export async function middleware(req: NextRequest) {
const { pathname, searchParams } = req.nextUrl;
console.log(pathname);
if (pathname === "/dashboards") {
const dashboardId = searchParams.get("dashboardId");
if (dashboardId) {
@ -12,6 +11,8 @@ export async function middleware(req: NextRequest) {
new URL(`/dashboards/view/${dashboardId}`, req.url)
);
}
} else if (pathname === "/capture") {
return NextResponse.redirect(new URL("/", req.url));
} else if (pathname === "/secretDashboard") {
const dashboardId = searchParams.get("dashboardId");
if (dashboardId) {

View File

@ -3,8 +3,8 @@ import React from "react";
import ReactMarkdown from "react-markdown";
import gfm from "remark-gfm";
import { Card } from "../web/display/Card";
import { Layout } from "../web/display/Layout";
import { Card } from "../web/common/Card";
import { Layout } from "../web/common/Layout";
const readmeMarkdownText = `# About
@ -31,10 +31,12 @@ Also note that, whatever other redeeming features they might have, prediction ma
const AboutPage: NextPage = () => {
return (
<Layout page="about">
<Card highlightOnHover={false}>
<div className="p-4">
<ReactMarkdown remarkPlugins={[gfm]} children={readmeMarkdownText} />
</div>
<Card highlightOnHover={false} large={true}>
<ReactMarkdown
remarkPlugins={[gfm]}
children={readmeMarkdownText}
className="max-w-prose mx-auto"
/>
</Card>
</Layout>
);

View File

@ -1,27 +0,0 @@
import { NextPage } from "next";
import React from "react";
import { displayQuestionsWrapperForCapture } from "../web/display/displayQuestionsWrappers";
import { Layout } from "../web/display/Layout";
import { Props } from "../web/search/anySearchPage";
import CommonDisplay from "../web/search/CommonDisplay";
export { getServerSideProps } from "../web/search/anySearchPage";
const CapturePage: NextPage<Props> = (props) => {
return (
<Layout page="capture">
<CommonDisplay
{...props}
hasSearchbar={true}
hasCapture={true}
hasAdvancedOptions={false}
placeholder="Get best title match..."
displaySeeMoreHint={false}
displayQuestionsWrapper={displayQuestionsWrapperForCapture}
/>
</Layout>
);
};
export default CapturePage;

View File

@ -4,7 +4,7 @@ import Error from "next/error";
import {
DashboardByIdDocument, DashboardFragment
} from "../../../web/dashboards/queries.generated";
import { DisplayQuestions } from "../../../web/display/DisplayQuestions";
import { QuestionCardsList } from "../../../web/questions/components/QuestionCardsList";
import { ssrUrql } from "../../../web/urql";
interface Props {
@ -52,7 +52,7 @@ const EmbedDashboardPage: NextPage<Props> = ({ dashboard, numCols }) => {
numCols || 3
} gap-4 mb-6`}
>
<DisplayQuestions
<QuestionCardsList
results={dashboard.questions}
numDisplay={dashboard.questions.length}
showIdToggle={false}

View File

@ -2,10 +2,10 @@ import { NextPage } from "next";
import { useRouter } from "next/router";
import { useMutation } from "urql";
import { Layout } from "../../web/common/Layout";
import { LineHeader } from "../../web/common/LineHeader";
import { CreateDashboardDocument } from "../../web/dashboards/queries.generated";
import { DashboardCreator } from "../../web/display/DashboardCreator";
import { Layout } from "../../web/display/Layout";
import { LineHeader } from "../../web/display/LineHeader";
const DashboardsPage: NextPage = () => {
const router = useRouter();

View File

@ -2,13 +2,13 @@ import { GetServerSideProps, NextPage } from "next";
import Error from "next/error";
import Link from "next/link";
import { InfoBox } from "../../../web/common/InfoBox";
import { Layout } from "../../../web/common/Layout";
import { LineHeader } from "../../../web/common/LineHeader";
import {
DashboardByIdDocument, DashboardFragment
} from "../../../web/dashboards/queries.generated";
import { DisplayQuestions } from "../../../web/display/DisplayQuestions";
import { InfoBox } from "../../../web/display/InfoBox";
import { Layout } from "../../../web/display/Layout";
import { LineHeader } from "../../../web/display/LineHeader";
import { QuestionCardsList } from "../../../web/questions/components/QuestionCardsList";
import { ssrUrql } from "../../../web/urql";
interface Props {
@ -84,7 +84,7 @@ const ViewDashboardPage: NextPage<Props> = ({ dashboard }) => {
<>
<DashboardMetadata dashboard={dashboard} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<DisplayQuestions
<QuestionCardsList
results={dashboard.questions}
numDisplay={dashboard.questions.length}
showIdToggle={false}

View File

@ -1,25 +1,86 @@
import { NextPage } from "next";
import { GetServerSideProps, NextPage } from "next";
import React from "react";
import { displayQuestionsWrapperForSearch } from "../web/display/displayQuestionsWrappers";
import { Layout } from "../web/display/Layout";
import { Props } from "../web/search/anySearchPage";
import CommonDisplay from "../web/search/CommonDisplay";
import { getPlatformsConfig, platforms } from "../backend/platforms";
import { Layout } from "../web/common/Layout";
import { Props, QueryParameters, SearchScreen } from "../web/search/components/SearchScreen";
import { FrontpageDocument, SearchDocument } from "../web/search/queries.generated";
import { ssrUrql } from "../web/urql";
export { getServerSideProps } from "../web/search/anySearchPage";
export const getServerSideProps: GetServerSideProps<Props> = async (
context
) => {
const [ssrCache, client] = ssrUrql();
const urlQuery = context.query;
const platformsConfig = getPlatformsConfig();
const defaultQueryParameters: QueryParameters = {
query: "",
starsThreshold: 2,
forecastsThreshold: 0,
forecastingPlatforms: platforms.map((platform) => platform.name),
};
const initialQueryParameters: QueryParameters = {
...defaultQueryParameters,
};
if (urlQuery.query) {
initialQueryParameters.query = String(urlQuery.query);
}
if (urlQuery.starsThreshold) {
initialQueryParameters.starsThreshold = Number(urlQuery.starsThreshold);
}
if (urlQuery.forecastsThreshold !== undefined) {
initialQueryParameters.forecastsThreshold = Number(
urlQuery.forecastsThreshold
);
}
if (urlQuery.forecastingPlatforms !== undefined) {
initialQueryParameters.forecastingPlatforms = String(
urlQuery.forecastingPlatforms
).split("|");
}
const defaultNumDisplay = 21;
const initialNumDisplay = Number(urlQuery.numDisplay) || defaultNumDisplay;
const defaultResults = (await client.query(FrontpageDocument).toPromise())
.data.result;
if (
!!initialQueryParameters &&
initialQueryParameters.query != "" &&
initialQueryParameters.query != undefined
) {
// must match the query from CommonDisplay
await client
.query(SearchDocument, {
input: {
...initialQueryParameters,
limit: initialNumDisplay,
},
})
.toPromise();
}
return {
props: {
urqlState: ssrCache.extractData(),
initialQueryParameters,
defaultQueryParameters,
initialNumDisplay,
defaultNumDisplay,
defaultResults,
platformsConfig,
},
};
};
const IndexPage: NextPage<Props> = (props) => {
return (
<Layout page="search">
<CommonDisplay
{...props}
hasSearchbar={true}
hasCapture={false}
hasAdvancedOptions={true}
placeholder={"Find forecasts about..."}
displaySeeMoreHint={true}
displayQuestionsWrapper={displayQuestionsWrapperForSearch}
/>
<SearchScreen {...props} />
</Layout>
);
};

View File

@ -4,8 +4,9 @@ import { GetServerSideProps, NextPage } from "next";
import React from "react";
import { platforms } from "../backend/platforms";
import { DisplayQuestion } from "../web/display/DisplayQuestion";
import { QuestionFragment, SearchDocument } from "../web/search/queries.generated";
import { QuestionFragment } from "../web/fragments.generated";
import { QuestionCard } from "../web/questions/components/QuestionCard";
import { SearchDocument } from "../web/search/queries.generated";
import { ssrUrql } from "../web/urql";
interface Props {
@ -57,7 +58,7 @@ const SecretEmbedPage: NextPage<Props> = ({ results }) => {
<div>
<div id="secretEmbed">
{result ? (
<DisplayQuestion
<QuestionCard
question={result}
showTimeStamp={true}
expandFooterToFullWidth={true}

View File

@ -1,14 +1,19 @@
import { NextPage } from "next";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import { Card } from "../web/display/Card";
import { Layout } from "../web/display/Layout";
import captureImg from "../../public/screenshots/capture.png";
import dashboardImg from "../../public/screenshots/dashboard.png";
import frontpageImg from "../../public/screenshots/frontpage.png";
import twitterImg from "../../public/screenshots/twitter.png";
import { Card } from "../web/common/Card";
import { Layout } from "../web/common/Layout";
type AnyTool = {
title: string;
description: string;
img?: string;
img?: StaticImageData;
};
type InnerTool = AnyTool & { innerLink: string };
@ -24,7 +29,7 @@ const ToolCard: React.FC<Tool> = (tool) => {
<div className="grid content-start gap-3">
<div className="text-gray-800 text-lg font-medium">{tool.title}</div>
<div className="text-gray-500">{tool.description}</div>
{tool.img && <img src={tool.img} className="text-gray-500" />}
{tool.img && <Image src={tool.img} className="text-gray-500" />}
</div>
</Card>
);
@ -52,32 +57,33 @@ const ToolsPage: NextPage = () => {
title: "Search",
description: "Find forecasting questions on many platforms.",
innerLink: "/",
img: "https://i.imgur.com/Q94gVqG.png",
img: frontpageImg,
},
{
title: "[Beta] Present",
description: "Present forecasts in dashboards.",
innerLink: "/dashboards",
img: "https://i.imgur.com/x8qkuHQ.png",
img: dashboardImg,
},
{
title: "Capture",
description:
"Capture forecasts save them to Imgur. Useful for posting them somewhere else as images. Currently rate limited by Imgur, so if you get a .gif of a fox falling flat on his face, that's why.",
innerLink: "/capture",
img: "https://i.imgur.com/EXkFBzz.png",
"Capture forecasts save them to Imgur. Useful for posting them somewhere else as images. Currently rate limited by Imgur, so if you get a .gif of a fox falling flat on his face, that's why. Capture button can be found on individual questions pages.",
innerLink: "/",
img: captureImg,
},
{
title: "Summon",
description:
"Summon metaforecast on Twitter by mentioning @metaforecast, or on Discord by using Fletcher and !metaforecast, followed by search terms.",
externalLink: "https://twitter.com/metaforecast",
img: "https://i.imgur.com/BQ4Zzjw.png",
img: twitterImg,
},
{
title: "[Upcoming] Request",
title: "[Beta] Request",
description:
"Interact with metaforecast's API and fetch forecasts for your application. Currently possible but documentation is poor, get in touch.",
"Interact with metaforecast's GraphQL API and fetch forecasts for your application. Currently possible but documentation is poor, get in touch.",
externalLink: "/api/graphql",
},
{
title: "[Upcoming] Record",

19
src/web/common/Button.tsx Normal file
View File

@ -0,0 +1,19 @@
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: "small" | "normal";
}
export const Button: React.FC<Props> = ({
children,
size = "normal",
...rest
}) => {
const padding = size === "normal" ? "px-5 py-4" : "px-3 py-2";
return (
<button
{...rest}
className={`bg-blue-500 cursor-pointer rounded-md shadow text-white hover:bg-blue-600 active:bg-gray-700 ${padding}`}
>
{children}
</button>
);
};

View File

@ -4,17 +4,22 @@ const CardTitle: React.FC = ({ children }) => (
interface Props {
highlightOnHover?: boolean;
large?: boolean;
}
type CardType = React.FC<Props> & {
Title: typeof CardTitle;
};
export const Card: CardType = ({ children, highlightOnHover = true }) => (
export const Card: CardType = ({
children,
large = false,
highlightOnHover = true,
}) => (
<div
className={`h-full px-4 py-3 bg-white rounded-md shadow ${
className={`h-full bg-white rounded-md shadow ${
highlightOnHover ? "hover:bg-gray-100" : ""
}`}
} ${large ? "p-5 sm:p-10" : "px-4 py-3"}`}
>
{children}
</div>

View File

@ -0,0 +1,35 @@
import { useState } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { Button } from "./Button";
// https://stackoverflow.com/questions/39501289/in-reactjs-how-to-copy-text-to-clipboard
export const CopyParagraph: React.FC<{ text: string; buttonText: string }> = ({
text,
buttonText: initialButtonText,
}) => {
const [buttonText, setButtonText] = useState(initialButtonText);
const handleButton = () => {
setButtonText("Copied");
setTimeout(async () => {
setButtonText(initialButtonText);
}, 2000);
};
return (
<div className="flex flex-col items-stretch">
<p
className="bg-gray-100 cursor-pointer px-3 py-2 rounded-md shadow text-gray-700 font-mono text-sm"
onClick={(e) => {
e.preventDefault();
navigator.clipboard.writeText(text);
}}
>
{text}
</p>
<CopyToClipboard text={text} onCopy={handleButton}>
<Button size="small">{buttonText}</Button>
</CopyToClipboard>
</div>
);
};

View File

@ -5,6 +5,8 @@ interface Props {
displayText: string;
}
// https://stackoverflow.com/questions/39501289/in-reactjs-how-to-copy-text-to-clipboard
export const CopyText: React.FC<Props> = ({ text, displayText }) => (
<div
className="flex items-center justify-center p-4 space-x-3 border rounded border-blue-400 hover:border-transparent bg-transparent hover:bg-blue-300 text-sm font-medium text-blue-400 hover:text-white cursor-pointer"

View File

@ -151,7 +151,7 @@ export const Layout: React.FC<Props> = ({ page, children }) => {
</nav>
<main>
<ErrorBoundary>
<div className="container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5">
<div className="container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5 mb-10">
{children}
</div>
</ErrorBoundary>

View File

@ -1,10 +1,16 @@
import chroma from "chroma-js";
import React from "react";
import Select from "react-select";
import Select, { StylesConfig } from "react-select";
import { PlatformConfig } from "../../backend/platforms";
const colourStyles = {
type Option = {
value: string;
label: string;
color: string;
};
const colourStyles: StylesConfig<Option> = {
control: (styles) => ({ ...styles, backgroundColor: "white" }),
option: (styles, { data, isDisabled, isFocused, isSelected }) => {
const color = chroma(data.color);
@ -70,12 +76,6 @@ export const MultiSelectPlatform: React.FC<Props> = ({
value,
platformsConfig,
}) => {
type Option = {
value: string;
label: string;
color: string;
};
const options: Option[] = platformsConfig.map((platform) => ({
value: platform.name,
label: platform.label,

View File

@ -1,7 +1,7 @@
import * as Types from '../../graphql/types.generated';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
import { QuestionFragmentDoc } from '../search/queries.generated';
import { QuestionFragmentDoc } from '../fragments.generated';
export type DashboardFragment = { __typename?: 'Dashboard', id: string, title: string, description: string, creator: string, questions: Array<{ __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } }> };
export type DashboardByIdQueryVariables = Types.Exact<{

View File

@ -1,10 +0,0 @@
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
export const Button: React.FC<Props> = ({ children, ...rest }) => (
<button
{...rest}
className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded text-center"
>
{children}
</button>
);

View File

@ -1,7 +1,7 @@
import React, { EventHandler, SyntheticEvent, useState } from "react";
import { Button } from "./Button";
import { InfoBox } from "./InfoBox";
import { Button } from "../common/Button";
import { InfoBox } from "../common/InfoBox";
const exampleInput = `{
"title": "Random example",

View File

@ -1,253 +0,0 @@
import domtoimage from "dom-to-image"; // https://github.com/tsayen/dom-to-image
import { useEffect, useRef, useState } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { QuestionFragment } from "../search/queries.generated";
import { uploadToImgur } from "../worker/uploadToImgur";
import { DisplayQuestion } from "./DisplayQuestion";
function displayOneQuestionInner(result: QuestionFragment, containerRef) {
return (
<div ref={containerRef}>
{result ? (
<DisplayQuestion
question={result}
showTimeStamp={true}
expandFooterToFullWidth={true}
/>
) : null}
</div>
);
}
let domToImageWrapper = (reactRef) => {
let node = reactRef.current;
const scale = 3; // Increase for better quality
const style = {
transform: "scale(" + scale + ")",
transformOrigin: "top left",
width: node.offsetWidth + "px",
height: node.offsetHeight + "px",
};
const param = {
height: node.offsetHeight * scale,
width: node.offsetWidth * scale,
quality: 1,
style,
};
let image = domtoimage.toPng(node, param);
return image;
};
let generateHtml = (result, imgSrc) => {
let html = `<a href="${result.url} target="_blank""><img src="${imgSrc}" alt="Metaforecast.org snapshot of ''${result.title}'', from ${result.platform}"></a>`;
return html;
};
let generateMarkdown = (result, imgSrc) => {
let markdown = `[![](${imgSrc})](${result.url})`;
return markdown;
};
let generateSource = (result, imgSrc, hasDisplayBeenCaptured) => {
const [htmlButtonStatus, setHtmlButtonStatus] = useState("Copy HTML");
const [markdownButtonStatus, setMarkdownButtonStatus] =
useState("Copy markdown");
let handleHtmlButton = () => {
setHtmlButtonStatus("Copied");
let newtimeoutId = setTimeout(async () => {
setHtmlButtonStatus("Copy HTML");
}, 2000);
};
let handleMarkdownButton = () => {
setMarkdownButtonStatus("Copied");
let newtimeoutId = setTimeout(async () => {
setMarkdownButtonStatus("Copy markdown");
}, 2000);
};
if (result && imgSrc && hasDisplayBeenCaptured) {
return (
<div className="grid">
<p className="bg-gray-100 cursor-pointer px-3 py-2 rounded-md shadow text-grey-7000 font-mono text-sm">
{generateMarkdown(result, imgSrc)}
</p>
<CopyToClipboard
text={generateMarkdown(result, imgSrc)}
onCopy={() => handleMarkdownButton()}
>
<button className="bg-blue-500 cursor-pointer px-3 py-2 rounded-md shadow text-white hover:bg-blue-600 active:scale-120">
{markdownButtonStatus}
</button>
</CopyToClipboard>
<p className="bg-gray-100 cursor-pointer px-3 py-2 rounded-md shadow text-grey-7000 font-mono text-sm">
{generateHtml(result, imgSrc)}
</p>
<CopyToClipboard
text={generateHtml(result, imgSrc)}
onCopy={() => handleHtmlButton()}
>
<button className="bg-blue-500 cursor-pointer px-3 py-2 rounded-md shadow text-white mb-4 hover:bg-blue-600">
{htmlButtonStatus}
</button>
</CopyToClipboard>
</div>
);
} else {
return null;
}
};
let generateIframeURL = (result) => {
let iframeURL = "";
if (result) {
// if check not strictly necessary today
let parts = result.url.replace("questions", "questions/embed").split("/");
parts.pop();
parts.pop();
iframeURL = parts.join("/");
}
return iframeURL;
};
let metaculusEmbed = (result) => {
let platform = "";
let iframeURL = "";
if (result) {
iframeURL = generateIframeURL(result);
platform = result.platform;
}
return (
<iframe
className={`${
platform == "Metaculus" ? "" : "hidden"
} flex h-80 w-full justify-self-center self-center`}
src={iframeURL}
/>
);
};
let generateMetaculusIframeHTML = (result) => {
if (result) {
let iframeURL = generateIframeURL(result);
return `<iframe src="${iframeURL}" height="400" width="600"/>`;
} else {
return null;
}
};
let generateMetaculusSource = (result, hasDisplayBeenCaptured) => {
const [htmlButtonStatus, setHtmlButtonStatus] = useState("Copy HTML");
let handleHtmlButton = () => {
setHtmlButtonStatus("Copied");
let newtimeoutId = setTimeout(async () => {
setHtmlButtonStatus("Copy HTML");
}, 2000);
};
if (result && hasDisplayBeenCaptured && result.platform == "Metaculus") {
return (
<div className="grid">
<p className="bg-gray-100 cursor-pointer px-3 py-2 rounded-md shadow text-grey-7000 font-mono text-sm">
{generateMetaculusIframeHTML(result)}
</p>
<CopyToClipboard
text={generateMetaculusIframeHTML(result)}
onCopy={() => handleHtmlButton()}
>
<button className="bg-blue-500 cursor-pointer px-3 py-2 rounded-md shadow text-white mb-4 hover:bg-blue-600">
{htmlButtonStatus}
</button>
</CopyToClipboard>
</div>
);
} else {
return null;
}
};
interface Props {
result: QuestionFragment;
}
export const DisplayOneQuestionForCapture: React.FC<Props> = ({ result }) => {
const [hasDisplayBeenCaptured, setHasDisplayBeenCaptured] = useState(false);
useEffect(() => {
setHasDisplayBeenCaptured(false);
}, [result]);
const containerRef = useRef(null);
const [imgSrc, setImgSrc] = useState("");
const [mainButtonStatus, setMainButtonStatus] = useState(
"Capture image and generate code"
);
let exportAsPictureAndCode = () => {
let handleGettingImgurlImage = (imgurUrl) => {
setImgSrc(imgurUrl);
setMainButtonStatus("Done!");
let newtimeoutId = setTimeout(async () => {
setMainButtonStatus("Capture image and generate code");
}, 2000);
};
domToImageWrapper(containerRef)
.then(async function (dataUrl) {
if (dataUrl) {
uploadToImgur(dataUrl, handleGettingImgurlImage);
}
})
.catch(function (error) {
console.error("oops, something went wrong!", error);
});
}; //
let onCaptureButtonClick = () => {
exportAsPictureAndCode();
setMainButtonStatus("Processing...");
setHasDisplayBeenCaptured(true);
setImgSrc("");
};
function generateCaptureButton(result, onCaptureButtonClick) {
if (result) {
return (
<button
onClick={() => onCaptureButtonClick()}
className="bg-blue-500 cursor-pointer px-5 py-4 rounded-md shadow text-white hover:bg-blue-600 active:bg-gray-700"
>
{mainButtonStatus}
</button>
);
}
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-center">
<div className="flex col-span-1 items-center justify-center">
{displayOneQuestionInner(result, containerRef)}
</div>
<div className="flex col-span-1 items-center justify-center">
{generateCaptureButton(result, onCaptureButtonClick)}
</div>
<div className="flex col-span-1 items-center justify-center">
<img src={imgSrc} className={hasDisplayBeenCaptured ? "" : "hidden"} />
</div>
<div className="flex col-span-1 items-center justify-center">
<div>{generateSource(result, imgSrc, hasDisplayBeenCaptured)}</div>
</div>
<div className="flex col-span-1 items-center justify-center mb-8">
{metaculusEmbed(result)}
</div>
<div className="flex col-span-1 items-center justify-center">
<div>{generateMetaculusSource(result, hasDisplayBeenCaptured)}</div>
</div>
<br></br>
</div>
);
};
// https://stackoverflow.com/questions/39501289/in-reactjs-how-to-copy-text-to-clipboard
// Note: https://stackoverflow.com/questions/66016033/can-no-longer-upload-images-to-imgur-from-localhost
// Use: http://imgurtester:3000/embed for testing.

View File

@ -1,31 +0,0 @@
import { DisplayOneQuestionForCapture } from "./DisplayOneQuestionForCapture";
import { DisplayQuestions } from "./DisplayQuestions";
export function displayQuestionsWrapperForSearch({
results,
numDisplay,
showIdToggle,
}) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<DisplayQuestions
results={results || []}
numDisplay={numDisplay}
showIdToggle={showIdToggle}
/>
</div>
);
}
export function displayQuestionsWrapperForCapture({
results,
whichResultToDisplayAndCapture,
}) {
return (
<div className="grid grid-cols-1 w-full justify-center">
<DisplayOneQuestionForCapture
result={results[whichResultToDisplayAndCapture]}
/>
</div>
);
}

View File

@ -0,0 +1,9 @@
import * as Types from '../graphql/types.generated';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type QuestionFragment = { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } };
export type QuestionWithHistoryFragment = { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, history: Array<{ __typename?: 'History', timestamp: number, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }> }>, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } };
export const QuestionFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Question"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Question"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"probability"}}]}},{"kind":"Field","name":{"kind":"Name","value":"platform"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}},{"kind":"Field","name":{"kind":"Name","value":"qualityIndicators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stars"}},{"kind":"Field","name":{"kind":"Name","value":"numForecasts"}},{"kind":"Field","name":{"kind":"Name","value":"numForecasters"}},{"kind":"Field","name":{"kind":"Name","value":"volume"}},{"kind":"Field","name":{"kind":"Name","value":"spread"}},{"kind":"Field","name":{"kind":"Name","value":"sharesVolume"}},{"kind":"Field","name":{"kind":"Name","value":"openInterest"}},{"kind":"Field","name":{"kind":"Name","value":"liquidity"}},{"kind":"Field","name":{"kind":"Name","value":"tradeVolume"}}]}},{"kind":"Field","name":{"kind":"Name","value":"visualization"}}]}}]} as unknown as DocumentNode<QuestionFragment, unknown>;
export const QuestionWithHistoryFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"QuestionWithHistory"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Question"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"probability"}}]}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<QuestionWithHistoryFragment, unknown>;

38
src/web/fragments.graphql Normal file
View File

@ -0,0 +1,38 @@
fragment Question on Question {
id
url
title
description
timestamp
options {
name
probability
}
platform {
id
label
}
qualityIndicators {
stars
numForecasts
numForecasters
volume
spread
sharesVolume
openInterest
liquidity
tradeVolume
}
visualization
}
fragment QuestionWithHistory on Question {
...Question
history {
timestamp
options {
name
probability
}
}
}

View File

@ -0,0 +1,155 @@
import domtoimage from "dom-to-image"; // https://github.com/tsayen/dom-to-image
import { useEffect, useRef, useState } from "react";
import { Button } from "../../common/Button";
import { CopyParagraph } from "../../common/CopyParagraph";
import { QuestionFragment } from "../../fragments.generated";
import { uploadToImgur } from "../../worker/uploadToImgur";
import { QuestionCard } from "./QuestionCard";
const domToImageWrapper = async (node: HTMLDivElement) => {
const scale = 3; // Increase for better quality
const style = {
transform: "scale(" + scale + ")",
transformOrigin: "top left",
width: node.offsetWidth + "px",
height: node.offsetHeight + "px",
};
const param = {
height: node.offsetHeight * scale,
width: node.offsetWidth * scale,
quality: 1,
style,
};
const image = await domtoimage.toPng(node, param);
return image;
};
const ImageSource: React.FC<{ question: QuestionFragment; imgSrc: string }> = ({
question,
imgSrc,
}) => {
if (!imgSrc) {
return null;
}
const html = `<a href="${question.url}" target="_blank"><img src="${imgSrc}" alt="Metaforecast.org snapshot of ''${question.title}'', from ${question.platform.label}"></a>`;
const markdown = `[![](${imgSrc})](${question.url})`;
return (
<div className="space-y-4">
<CopyParagraph text={markdown} buttonText="Copy markdown" />
<CopyParagraph text={html} buttonText="Copy HTML" />
</div>
);
};
const generateMetaculusIframeURL = (question: QuestionFragment) => {
let parts = question.url.replace("questions", "questions/embed").split("/");
parts.pop();
parts.pop();
const iframeURL = parts.join("/");
return iframeURL;
};
const generateMetaculusIframeHTML = (question: QuestionFragment) => {
const iframeURL = generateMetaculusIframeURL(question);
return `<iframe src="${iframeURL}" height="400" width="600"/>`;
};
const MetaculusEmbed: React.FC<{ question: QuestionFragment }> = ({
question,
}) => {
if (question.platform.id !== "metaculus") return null;
const iframeURL = generateMetaculusIframeURL(question);
return <iframe className="w-full h-80" src={iframeURL} />;
};
const MetaculusSource: React.FC<{
question: QuestionFragment;
}> = ({ question }) => {
if (question.platform.id !== "metaculus") return null;
return (
<CopyParagraph
text={generateMetaculusIframeHTML(question)}
buttonText="Copy HTML"
/>
);
};
interface Props {
question: QuestionFragment;
}
export const CaptureQuestion: React.FC<Props> = ({ question }) => {
const [imgSrc, setImgSrc] = useState<string | null>(null);
useEffect(() => {
setImgSrc(null);
}, [question]);
const containerRef = useRef<HTMLDivElement | null>(null);
const initialMainButtonText = "Capture image and generate code";
const [mainButtonText, setMainButtonText] = useState(initialMainButtonText);
const exportAsPictureAndCode = async () => {
if (!containerRef.current) {
return;
}
try {
const dataUrl = await domToImageWrapper(containerRef.current);
const imgurUrl = await uploadToImgur(dataUrl);
setImgSrc(imgurUrl);
setMainButtonText("Done!");
setTimeout(async () => {
setMainButtonText(initialMainButtonText);
}, 2000);
} catch (error) {
console.error("oops, something went wrong!", error);
}
};
const onCaptureButtonClick = async () => {
setMainButtonText("Processing...");
setImgSrc(null);
await exportAsPictureAndCode();
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 place-items-center">
<div ref={containerRef}>
<QuestionCard
question={question}
showTimeStamp={true}
showExpandButton={false}
expandFooterToFullWidth={true}
/>
</div>
<div>
<Button onClick={onCaptureButtonClick}>{mainButtonText}</Button>
</div>
{imgSrc ? (
<>
<div>
<img src={imgSrc} />
</div>
<div>
<ImageSource question={question} imgSrc={imgSrc} />
</div>
<div className="justify-self-stretch">
<MetaculusEmbed question={question} />
</div>
<div>
<MetaculusSource question={question} />
</div>
</>
) : null}
</div>
);
};
// Note: https://stackoverflow.com/questions/66016033/can-no-longer-upload-images-to-imgur-from-localhost
// Use: http://imgurtester:3000/embed for testing.

View File

@ -0,0 +1,167 @@
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;
}) => {
return (
<VictoryGroup color={chartColors[i] || "darkgray"} data={data} key={i}>
<VictoryLine
name={`line-${i}`}
style={{
data: {
strokeOpacity: highlight ? 1 : 0.5,
},
}}
/>
<VictoryScatter
name={`scatter-${i}`}
size={({ active }) => (active || highlight ? 3.75 : 3)}
/>
</VictoryGroup>
);
};
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: 5,
};
return (
<VictoryChart
domainPadding={20}
padding={padding}
theme={VictoryTheme.material}
height={height}
width={width}
containerComponent={
<VictoryVoronoiContainer
labels={() => "Not shown"}
labelComponent={
<VictoryTooltip
constrainToVisibleArea
pointerLength={0}
dy={-12}
labelComponent={
<VictoryLabel
style={[
{
fontSize: 18,
fill: "black",
strokeWidth: 0.05,
},
{
fontSize: 18,
fill: "#777",
strokeWidth: 0.05,
},
]}
/>
}
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],
}}
>
<VictoryAxis
tickCount={Math.min(7, differenceInDays(maxDate, minDate) + 1)}
style={{
grid: { stroke: null, strokeWidth: 0.5 },
}}
tickLabelComponent={
<VictoryLabel
dx={-38}
dy={-5}
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}%`}
/>
{[...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,
})}
</VictoryChart>
);
};

View File

@ -0,0 +1,11 @@
import { height, width } from "./utils";
export const InnerChartPlaceholder: React.FC = () => {
return (
<svg
width={width}
height={height}
style={{ width: "100%", height: "100%" }}
/>
);
};

View File

@ -0,0 +1,85 @@
import { useRef, useState } from "react";
import { shift, useFloating } from "@floating-ui/react-dom";
type Item = {
name: string;
color: string;
};
const LegendItem: React.FC<{ item: Item; onHighlight: () => void }> = ({
item,
onHighlight,
}) => {
const { x, y, reference, floating, strategy } = useFloating({
// placement: "right",
middleware: [shift()],
});
const [showTooltip, setShowTooltip] = useState(false);
const textRef = useRef<HTMLDivElement>();
const onHover = () => {
if (textRef.current.scrollWidth > textRef.current.clientWidth) {
setShowTooltip(true);
}
onHighlight();
};
return (
<>
<div
className="flex items-center cursor-pointer"
onMouseOver={onHover}
onMouseLeave={() => setShowTooltip(false)}
ref={reference}
>
<svg className="mt-1 shrink-0" height="10" width="16">
<circle cx="4" cy="4" r="4" fill={item.color} />
</svg>
<div
className="text-xs sm:text-sm sm:whitespace-nowrap sm:text-ellipsis sm:overflow-hidden sm:max-w-160"
ref={textRef}
>
{item.name}
</div>
</div>
{showTooltip
? (() => {
return (
<div
className={`absolute text-xs p-2 border border-gray-300 rounded bg-white ${
showTooltip ? "" : "hidden"
}`}
ref={floating}
style={{
position: strategy,
top: y ?? "",
left: x ?? "",
}}
>
{item.name}
</div>
);
})()
: null}
</>
);
};
export 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) => (
<LegendItem
key={item.name}
item={item}
onHighlight={() => setHighlight(i)}
/>
))}
</div>
);
};

View File

@ -0,0 +1,36 @@
import dynamic from "next/dynamic";
import React, { useMemo, useState } from "react";
import { QuestionWithHistoryFragment } from "../../../fragments.generated";
import { InnerChartPlaceholder } from "./InnerChartPlaceholder";
import { Legend } from "./Legend";
import { buildChartData, chartColors } from "./utils";
const InnerChart = dynamic(
() => import("./InnerChart").then((mod) => mod.InnerChart),
{ ssr: false, loading: () => <InnerChartPlaceholder /> }
);
interface Props {
question: QuestionWithHistoryFragment;
}
export const HistoryChart: React.FC<Props> = ({ question }) => {
// maybe use context instead?
const [highlight, setHighlight] = useState<number | undefined>(undefined);
const data = useMemo(() => buildChartData(question), [question]);
return (
<div className="flex items-center flex-col space-y-4 sm:flex-row sm:space-y-0">
<InnerChart data={data} highlight={highlight} />
<Legend
items={data.seriesNames.map((name, i) => ({
name,
color: chartColors[i],
}))}
setHighlight={setHighlight}
/>
</div>
);
};

View File

@ -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,
};
};

View File

@ -0,0 +1,69 @@
import { QuestionFragment } from "../../fragments.generated";
import {
formatIndicatorValue, qualityIndicatorLabels, UsedIndicatorName
} from "./QuestionCard/QuestionFooter";
import { Stars } from "./Stars";
interface Props {
question: QuestionFragment;
}
const TableRow: React.FC<{ title: string }> = ({ title, children }) => (
<tr className="border-b">
<th
scope="row"
className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap"
>
{title}
</th>
<td className="px-6 py-4">{children}</td>
</tr>
);
export const IndicatorsTable: React.FC<Props> = ({ question }) => (
<div className="relative overflow-x-auto shadow-md sm:rounded-lg">
<table className="w-full text-sm text-left text-gray-500">
<thead className="text-xs text-gray-700 uppercase bg-gray-100">
<tr>
<th scope="col" className="px-6 py-3">
Indicator
</th>
<th scope="col" className="px-6 py-3">
Value
</th>
</tr>
</thead>
<tbody>
<TableRow title="Stars">
<Stars num={question.qualityIndicators.stars} />
</TableRow>
<TableRow title="Platform">{question.platform.label}</TableRow>
{question.qualityIndicators.numForecasts ? (
<TableRow title="Number of forecasts">
{question.qualityIndicators.numForecasts}
</TableRow>
) : null}
{Object.keys(question.qualityIndicators)
.filter(
(indicator) =>
question.qualityIndicators[indicator] != null &&
!!qualityIndicatorLabels[indicator]
)
.map((indicator: UsedIndicatorName) => {
return (
<TableRow
title={qualityIndicatorLabels[indicator]}
key={indicator}
>
{formatIndicatorValue(
question.qualityIndicators[indicator],
indicator,
question.platform.id
)}
</TableRow>
);
})}
</tbody>
</table>
</div>
);

View File

@ -0,0 +1,177 @@
import { QuestionFragment } from "../../../fragments.generated";
import { Stars } from "../Stars";
type QualityIndicator = QuestionFragment["qualityIndicators"];
type IndicatorName = keyof QualityIndicator;
// this duplication can probably be simplified with typescript magic, but this is good enough for now
export type UsedIndicatorName =
// | "numForecasts"
// | "stars"
| "volume"
| "numForecasters"
| "spread"
| "sharesVolume"
| "liquidity"
| "tradeVolume"
| "openInterest";
export const qualityIndicatorLabels: { [k in UsedIndicatorName]: string } = {
// numForecasts: "Number of forecasts",
// stars: "Stars",
// yesBid: "Yes bid",
// yesAsk: "Yes ask",
volume: "Volume",
numForecasters: "Forecasters",
spread: "Spread",
sharesVolume: "Shares vol.",
liquidity: "Liquidity",
tradeVolume: "Volume",
openInterest: "Interest",
};
const formatNumber = (num) => {
if (Number(num) < 1000) {
return Number(num).toFixed(0);
} else if (num < 10000) {
return (Number(num) / 1000).toFixed(1) + "k";
} else {
return (Number(num) / 1000).toFixed(0) + "k";
}
};
/* Display functions*/
const getPercentageSymbolIfNeeded = ({
indicator,
platform,
}: {
indicator: UsedIndicatorName;
platform: string;
}) => {
let indicatorsWhichNeedPercentageSymbol: IndicatorName[] = ["spread"];
if (indicatorsWhichNeedPercentageSymbol.includes(indicator)) {
return "%";
} else {
return "";
}
};
const getCurrencySymbolIfNeeded = ({
indicator,
platform,
}: {
indicator: UsedIndicatorName;
platform: string;
}) => {
const indicatorsWhichNeedCurrencySymbol: IndicatorName[] = [
"volume",
"tradeVolume",
"openInterest",
"liquidity",
];
let dollarPlatforms = ["predictit", "kalshi", "polymarket"];
if (indicatorsWhichNeedCurrencySymbol.includes(indicator)) {
if (dollarPlatforms.includes(platform)) {
return "$";
} else {
return "£";
}
} else {
return "";
}
};
const FirstQualityIndicator: React.FC<{
question: QuestionFragment;
}> = ({ question }) => {
if (question.qualityIndicators.numForecasts) {
return (
<div className="flex">
<span>Forecasts:</span>&nbsp;
<span className="font-bold">
{Number(question.qualityIndicators.numForecasts).toFixed(0)}
</span>
</div>
);
} else {
return null;
}
};
export const formatIndicatorValue = (
value: any,
indicator: UsedIndicatorName,
platform: string
): string => {
return `${getCurrencySymbolIfNeeded({
indicator,
platform: platform,
})}${formatNumber(value)}${getPercentageSymbolIfNeeded({
indicator,
platform: platform,
})}`;
};
const QualityIndicatorsList: React.FC<{
question: QuestionFragment;
}> = ({ question }) => {
return (
<div className="text-sm">
<FirstQualityIndicator question={question} />
{Object.entries(question.qualityIndicators).map((entry, i) => {
const indicatorLabel = qualityIndicatorLabels[entry[0]];
if (!indicatorLabel || entry[1] === null) return;
const indicator = entry[0] as UsedIndicatorName; // guaranteed by the previous line
const value = entry[1];
return (
<div key={indicator}>
<span>{indicatorLabel}:</span>&nbsp;
<span className="font-bold">
{formatIndicatorValue(value, indicator, question.platform.id)}
</span>
</div>
);
})}
</div>
);
};
interface Props {
question: QuestionFragment;
expandFooterToFullWidth: boolean;
}
export const QuestionFooter: React.FC<Props> = ({
question,
expandFooterToFullWidth,
}) => {
return (
<div
className={`grid grid-cols-3 ${
expandFooterToFullWidth ? "justify-between" : ""
} text-gray-500 mb-2 mt-1`}
>
<Stars num={question.qualityIndicators.stars} />
<div
className={`${
expandFooterToFullWidth ? "place-self-center" : "self-center"
} col-span-1 font-bold`}
>
{question.platform.label
.replace("Good Judgment Open", "GJOpen")
.replace(/ /g, "\u00a0")}
</div>
<div
className={`${
expandFooterToFullWidth
? "justify-self-end mr-4"
: "justify-self-center"
} col-span-1`}
>
<QualityIndicatorsList question={question} />
</div>
</div>
);
};

View File

@ -2,10 +2,11 @@ import Link from "next/link";
import { FaExpand } from "react-icons/fa";
import ReactMarkdown from "react-markdown";
import { CopyText } from "../../common/CopyText";
import { QuestionOptions } from "../../questions/components/QuestionOptions";
import { QuestionFragment } from "../../search/queries.generated";
import { Card } from "../Card";
import { Card } from "../../../common/Card";
import { CopyText } from "../../../common/CopyText";
import { QuestionFragment } from "../../../fragments.generated";
import { cleanText } from "../../../utils";
import { QuestionOptions } from "../QuestionOptions";
import { QuestionFooter } from "./QuestionFooter";
const truncateText = (length: number, text: string): string => {
@ -68,24 +69,6 @@ if (!String.prototype.replaceAll) {
};
}
const cleanText = (text: string): string => {
// Note: should no longer be necessary
let textString = !!text ? text : "";
textString = textString
.replaceAll("] (", "](")
.replaceAll(") )", "))")
.replaceAll("( [", "([")
.replaceAll(") ,", "),")
.replaceAll("==", "") // Denotes a title in markdown
.replaceAll("Background\n", "")
.replaceAll("Context\n", "")
.replaceAll("--- \n", "- ")
.replaceAll(/\[(.*?)\]\(.*?\)/g, "$1");
textString = textString.slice(0, 1) == "=" ? textString.slice(1) : textString;
//console.log(textString)
return textString;
};
// Auxiliary components
const DisplayMarkdown: React.FC<{ description: string }> = ({
@ -121,13 +104,15 @@ interface Props {
showTimeStamp: boolean;
expandFooterToFullWidth: boolean;
showIdToggle?: boolean;
showExpandButton?: boolean;
}
export const DisplayQuestion: React.FC<Props> = ({
export const QuestionCard: React.FC<Props> = ({
question,
showTimeStamp,
expandFooterToFullWidth,
showIdToggle,
showExpandButton = true,
}) => {
const { options } = question;
const lastUpdated = new Date(question.timestamp * 1000);
@ -146,7 +131,7 @@ export const DisplayQuestion: React.FC<Props> = ({
</div>
) : null}
<div>
{process.env.NEXT_PUBLIC_ENABLE_QUESTION_PAGES ? (
{showExpandButton ? (
<Link href={`/questions/${question.id}`} passHref>
<a className="float-right block ml-2 mt-1.5">
<FaExpand

View File

@ -1,7 +1,7 @@
import React from "react";
import { QuestionFragment } from "../search/queries.generated";
import { DisplayQuestion } from "./DisplayQuestion";
import { QuestionFragment } from "../../fragments.generated";
import { QuestionCard } from "./QuestionCard";
interface Props {
results: QuestionFragment[];
@ -9,18 +9,18 @@ interface Props {
showIdToggle: boolean;
}
export const DisplayQuestions: React.FC<Props> = ({
export const QuestionCardsList: React.FC<Props> = ({
results,
numDisplay,
showIdToggle,
}) => {
if (!results) {
return <></>;
return null;
}
return (
<>
{results.slice(0, numDisplay).map((result) => (
<DisplayQuestion
<QuestionCard
key={result.id}
question={result}
showTimeStamp={false}

View File

@ -1,4 +1,4 @@
import { QuestionFragment } from "../../search/queries.generated";
import { QuestionFragment } from "../../fragments.generated";
type QualityIndicator = QuestionFragment["qualityIndicators"];
type IndicatorName = keyof QualityIndicator;

View File

@ -1,4 +1,4 @@
import { QuestionFragment } from "../../search/queries.generated";
import { QuestionFragment } from "../../fragments.generated";
import { formatProbability } from "../utils";
type Option = QuestionFragment["options"][0];

View File

@ -0,0 +1,62 @@
// Database-like functions
export function getstars(numstars: number) {
let stars = "★★☆☆☆";
switch (numstars) {
case 0:
stars = "☆☆☆☆☆";
break;
case 1:
stars = "★☆☆☆☆";
break;
case 2:
stars = "★★☆☆☆";
break;
case 3:
stars = "★★★☆☆";
break;
case 4:
stars = "★★★★☆";
break;
case 5:
stars = "★★★★★";
break;
default:
stars = "★★☆☆☆";
}
return stars;
}
function getStarsColor(numstars: number) {
let color = "text-yellow-400";
switch (numstars) {
case 0:
color = "text-red-400";
break;
case 1:
color = "text-red-400";
break;
case 2:
color = "text-orange-400";
break;
case 3:
color = "text-yellow-400";
break;
case 4:
color = "text-green-400";
break;
case 5:
color = "text-blue-400";
break;
default:
color = "text-yellow-400";
}
return color;
}
export const Stars: React.FC<{ num: number }> = ({ num }) => {
return (
<div className={`self-center col-span-1 ${getStarsColor(num)}`}>
{getstars(num)}
</div>
);
};

View File

@ -1,14 +1,18 @@
import { GetServerSideProps, NextPage } from "next";
import { FaExternalLinkAlt } from "react-icons/fa";
import ReactMarkdown from "react-markdown";
import { Card } from "../../common/Card";
import { Layout } from "../../common/Layout";
import { LineHeader } from "../../common/LineHeader";
import { Query } from "../../common/Query";
import { Card } from "../../display/Card";
import { QuestionFooter } from "../../display/DisplayQuestion/QuestionFooter";
import { Layout } from "../../display/Layout";
import { QuestionFragment } from "../../search/queries.generated";
import { QuestionWithHistoryFragment } from "../../fragments.generated";
import { ssrUrql } from "../../urql";
import { QuestionOptions } from "../components/QuestionOptions";
import { QuestionByIdDocument } from "../queries.generated";
import { CaptureQuestion } from "../components/CaptureQuestion";
import { HistoryChart } from "../components/HistoryChart";
import { IndicatorsTable } from "../components/IndicatorsTable";
import { Stars } from "../components/Stars";
import { QuestionPageDocument } from "../queries.generated";
interface Props {
id: string;
@ -21,7 +25,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
const id = context.query.id as string;
const question =
(await client.query(QuestionByIdDocument, { id }).toPromise()).data
(await client.query(QuestionPageDocument, { id }).toPromise()).data
?.result || null;
if (!question) {
@ -36,37 +40,87 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
};
};
const QuestionCardContents: React.FC<{ question: QuestionFragment }> = ({
question,
}) => (
<div className="space-y-4">
<h1>
const Section: React.FC<{ title: string }> = ({ title, children }) => (
<div className="space-y-2 flex flex-col items-start">
<h2 className="text-xl text-gray-900">{title}</h2>
<div>{children}</div>
</div>
);
const LargeQuestionCard: React.FC<{
question: QuestionWithHistoryFragment;
}> = ({ question }) => (
<Card highlightOnHover={false} large={true}>
<h1 className="sm:text-3xl text-xl">
<a
className="text-black no-underline"
className="text-black no-underline hover:text-gray-700"
href={question.url}
target="_blank"
>
{question.title}
{question.title}{" "}
<FaExternalLinkAlt className="text-gray-400 inline sm:text-3xl text-xl mb-1" />
</a>
</h1>
<QuestionFooter question={question} expandFooterToFullWidth={true} />
<QuestionOptions options={question.options} />
<ReactMarkdown linkTarget="_blank" className="font-normal">
{question.description}
<div className="flex gap-2 mb-2">
<a
className="text-black no-underline bg-red-300 rounded p-1 px-2 text-xs hover:text-gray-600"
href={question.url}
target="_blank"
>
{question.platform.label}
</a>
<Stars num={question.qualityIndicators.stars} />
</div>
<div className="mb-8">
{question.platform.id === "guesstimate" ? (
<a className="no-underline" href={question.url} target="_blank">
<img
className="rounded-sm"
src={question.visualization}
alt="Guesstimate Screenshot"
/>
</a>
) : (
<HistoryChart question={question} />
)}
</div>
<ReactMarkdown
linkTarget="_blank"
className="font-normal text-gray-900 max-w-prose"
>
{question.description.replaceAll("---", "")}
</ReactMarkdown>
<Section title="Indicators">
<IndicatorsTable question={question} />
</Section>
</Card>
);
const QuestionScreen: React.FC<{ question: QuestionWithHistoryFragment }> = ({
question,
}) => (
<div className="space-y-8">
<LargeQuestionCard question={question} />
<div className="space-y-4">
<LineHeader>
<h1>Capture</h1>
</LineHeader>
<CaptureQuestion question={question} />
</div>
</div>
);
const QuestionPage: NextPage<Props> = ({ id }) => {
return (
<Layout page="question">
<div className="max-w-2xl mx-auto">
<Card highlightOnHover={false}>
<Query document={QuestionByIdDocument} variables={{ id }}>
{({ data }) => <QuestionCardContents question={data.result} />}
</Query>
</Card>
<div className="max-w-4xl mx-auto">
<Query document={QuestionPageDocument} variables={{ id }}>
{({ data }) => <QuestionScreen question={data.result} />}
</Query>
</div>
</Layout>
);

View File

@ -1,13 +1,13 @@
import * as Types from '../../graphql/types.generated';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
import { QuestionFragmentDoc } from '../search/queries.generated';
export type QuestionByIdQueryVariables = Types.Exact<{
import { QuestionWithHistoryFragmentDoc } from '../fragments.generated';
export type QuestionPageQueryVariables = Types.Exact<{
id: Types.Scalars['ID'];
}>;
export type QuestionByIdQuery = { __typename?: 'Query', result: { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } } };
export type QuestionPageQuery = { __typename?: 'Query', result: { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, history: Array<{ __typename?: 'History', timestamp: number, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }> }>, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } } };
export const QuestionByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"QuestionById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"question"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<QuestionByIdQuery, QuestionByIdQueryVariables>;
export const QuestionPageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"QuestionPage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"question"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"QuestionWithHistory"}}]}}]}},...QuestionWithHistoryFragmentDoc.definitions]} as unknown as DocumentNode<QuestionPageQuery, QuestionPageQueryVariables>;

View File

@ -1,5 +1,5 @@
query QuestionById($id: ID!) {
query QuestionPage($id: ID!) {
result: question(id: $id) {
...Question
...QuestionWithHistory
}
}

View File

@ -1,93 +0,0 @@
import { GetServerSideProps } from "next";
import { getPlatformsConfig, PlatformConfig, platforms } from "../../backend/platforms";
import { ssrUrql } from "../urql";
import { FrontpageDocument, QuestionFragment, SearchDocument } from "./queries.generated";
/* Common code for / and /capture */
export interface QueryParameters {
query: string;
starsThreshold: number;
forecastsThreshold: number;
forecastingPlatforms: string[]; // platform names
}
export interface Props {
defaultResults: QuestionFragment[];
initialQueryParameters: QueryParameters;
defaultQueryParameters: QueryParameters;
initialNumDisplay: number;
defaultNumDisplay: number;
platformsConfig: PlatformConfig[];
}
export const getServerSideProps: GetServerSideProps<Props> = async (
context
) => {
const [ssrCache, client] = ssrUrql();
const urlQuery = context.query;
const platformsConfig = getPlatformsConfig({ withGuesstimate: true });
const defaultQueryParameters: QueryParameters = {
query: "",
starsThreshold: 2,
forecastsThreshold: 0,
forecastingPlatforms: platforms.map((platform) => platform.name),
};
const initialQueryParameters: QueryParameters = {
...defaultQueryParameters,
};
if (urlQuery.query) {
initialQueryParameters.query = String(urlQuery.query);
}
if (urlQuery.starsThreshold) {
initialQueryParameters.starsThreshold = Number(urlQuery.starsThreshold);
}
if (urlQuery.forecastsThreshold !== undefined) {
initialQueryParameters.forecastsThreshold = Number(
urlQuery.forecastsThreshold
);
}
if (urlQuery.forecastingPlatforms !== undefined) {
initialQueryParameters.forecastingPlatforms = String(
urlQuery.forecastingPlatforms
).split("|");
}
const defaultNumDisplay = 21;
const initialNumDisplay = Number(urlQuery.numDisplay) || defaultNumDisplay;
const defaultResults = (await client.query(FrontpageDocument).toPromise())
.data.result;
if (
!!initialQueryParameters &&
initialQueryParameters.query != "" &&
initialQueryParameters.query != undefined
) {
// must match the query from CommonDisplay
await client
.query(SearchDocument, {
input: {
...initialQueryParameters,
limit: initialNumDisplay,
},
})
.toPromise();
}
return {
props: {
urqlState: ssrCache.extractData(),
initialQueryParameters,
defaultQueryParameters,
initialNumDisplay,
defaultNumDisplay,
defaultResults,
platformsConfig,
},
};
};

View File

@ -1,5 +1,3 @@
import React from "react";
interface Props {
value: string;
onChange: (v: string) => void;

View File

@ -2,42 +2,40 @@ import { useRouter } from "next/router";
import React, { Fragment, useMemo, useState } from "react";
import { useQuery } from "urql";
import { ButtonsForStars } from "../display/ButtonsForStars";
import { MultiSelectPlatform } from "../display/MultiSelectPlatform";
import { QueryForm } from "../display/QueryForm";
import { SliderElement } from "../display/SliderElement";
import { useIsFirstRender, useNoInitialEffect } from "../hooks";
import { Props as AnySearchPageProps, QueryParameters } from "./anySearchPage";
import { QuestionFragment, SearchDocument } from "./queries.generated";
import { PlatformConfig } from "../../../backend/platforms";
import { MultiSelectPlatform } from "../../common/MultiSelectPlatform";
import { ButtonsForStars } from "../../display/ButtonsForStars";
import { SliderElement } from "../../display/SliderElement";
import { QuestionFragment } from "../../fragments.generated";
import { useIsFirstRender, useNoInitialEffect } from "../../hooks";
import { QuestionCardsList } from "../../questions/components/QuestionCardsList";
import { SearchDocument } from "../queries.generated";
import { QueryForm } from "./QueryForm";
interface Props extends AnySearchPageProps {
hasSearchbar: boolean;
hasCapture: boolean;
hasAdvancedOptions: boolean;
placeholder: string;
displaySeeMoreHint: boolean;
displayQuestionsWrapper: (opts: {
results: QuestionFragment[];
numDisplay: number;
whichResultToDisplayAndCapture: number;
showIdToggle: boolean;
}) => React.ReactNode;
export interface QueryParameters {
query: string;
starsThreshold: number;
forecastsThreshold: number;
forecastingPlatforms: string[]; // platform names
}
export interface Props {
defaultResults: QuestionFragment[];
initialQueryParameters: QueryParameters;
defaultQueryParameters: QueryParameters;
initialNumDisplay: number;
defaultNumDisplay: number;
platformsConfig: PlatformConfig[];
}
/* Body */
const CommonDisplay: React.FC<Props> = ({
export const SearchScreen: React.FC<Props> = ({
defaultResults,
initialQueryParameters,
defaultQueryParameters,
initialNumDisplay,
defaultNumDisplay,
platformsConfig,
hasSearchbar,
hasCapture,
hasAdvancedOptions,
placeholder,
displaySeeMoreHint,
displayQuestionsWrapper,
}) => {
/* States */
const router = useRouter();
@ -53,8 +51,6 @@ const CommonDisplay: React.FC<Props> = ({
const [forceSearch, setForceSearch] = useState(0);
const [advancedOptions, showAdvancedOptions] = useState(false);
const [whichResultToDisplayAndCapture, setWhichResultToDisplayAndCapture] =
useState(0);
const [showIdToggle, setShowIdToggle] = useState(false);
const [typing, setTyping] = useState(false);
@ -118,12 +114,15 @@ const CommonDisplay: React.FC<Props> = ({
numDisplay % 3 != 0
? numDisplay + (3 - (Math.round(numDisplay) % 3))
: numDisplay;
return displayQuestionsWrapper({
results,
numDisplay: numDisplayRounded,
whichResultToDisplayAndCapture,
showIdToggle,
});
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<QuestionCardsList
results={results}
numDisplay={numDisplayRounded}
showIdToggle={showIdToggle}
/>
</div>
);
};
const updateRoute = () => {
@ -217,7 +216,7 @@ const CommonDisplay: React.FC<Props> = ({
};
/* Change selected platforms */
const onChangeSelectedPlatforms = (value) => {
const onChangeSelectedPlatforms = (value: string[]) => {
setQueryParameters({
...queryParameters,
forecastingPlatforms: value,
@ -229,61 +228,29 @@ const CommonDisplay: React.FC<Props> = ({
setShowIdToggle(!showIdToggle);
};
// Capture functionality
const onClickBack = () => {
const decreaseUntil0 = (num: number) => (num - 1 > 0 ? num - 1 : 0);
setWhichResultToDisplayAndCapture(
decreaseUntil0(whichResultToDisplayAndCapture)
);
};
const onClickForward = (whichResultToDisplayAndCapture: number) => {
setWhichResultToDisplayAndCapture(whichResultToDisplayAndCapture + 1);
};
/* Final return */
return (
<Fragment>
<label className="mb-4 mt-4 flex flex-row justify-center items-center">
{hasSearchbar ? (
<div className="w-10/12 mb-2">
<QueryForm
value={queryParameters.query}
onChange={onChangeSearchBar}
placeholder={placeholder}
/>
</div>
) : null}
<div className="w-10/12 mb-2">
<QueryForm
value={queryParameters.query}
onChange={onChangeSearchBar}
placeholder="Find forecasts about..."
/>
</div>
{hasAdvancedOptions ? (
<div className="w-2/12 flex justify-center ml-4 md:ml-2 lg:ml-0">
<button
className="text-gray-500 text-sm mb-2"
onClick={() => showAdvancedOptions(!advancedOptions)}
>
Advanced options
</button>
</div>
) : null}
{hasCapture ? (
<div className="w-2/12 flex justify-center ml-4 md:ml-2 gap-1 lg:ml-0">
<button
className="text-blue-500 cursor-pointer text-xl mb-3 pr-3 hover:text-blue-600"
onClick={() => onClickBack()}
>
</button>
<button
className="text-blue-500 cursor-pointer text-xl mb-3 pl-3 hover:text-blue-600"
onClick={() => onClickForward(whichResultToDisplayAndCapture)}
>
</button>
</div>
) : null}
<div className="w-2/12 flex justify-center ml-4 md:ml-2 lg:ml-0">
<button
className="text-gray-500 text-sm mb-2"
onClick={() => showAdvancedOptions(!advancedOptions)}
>
Advanced options
</button>
</div>
</label>
{hasAdvancedOptions && advancedOptions ? (
{advancedOptions ? (
<div className="flex-1 flex-col mx-auto justify-center items-center w-full">
<div className="grid sm:grid-rows-4 sm:grid-cols-1 md:grid-rows-2 lg:grid-rows-2 grid-cols-1 md:grid-cols-3 lg:grid-cols-3 items-center content-center bg-gray-50 rounded-md px-8 pt-4 pb-1 shadow mb-4">
<div className="flex row-start-1 row-end-1 col-start-1 col-end-4 md:row-span-1 md:col-start-1 md:col-end-1 md:row-start-1 md:row-end-1 lg:row-span-1 lg:col-start-1 lg:col-end-1 lg:row-start-1 lg:row-end-1 items-center justify-center mb-4">
@ -325,8 +292,7 @@ const CommonDisplay: React.FC<Props> = ({
<div>{getInfoToDisplayQuestionsFunction()}</div>
{displaySeeMoreHint &&
(!results || (results.length !== 0 && numDisplay < results.length)) ? (
{!results || (results.length !== 0 && numDisplay < results.length) ? (
<div>
<p className="mt-4 mb-4">
{"Can't find what you were looking for?"}
@ -351,10 +317,6 @@ const CommonDisplay: React.FC<Props> = ({
</p>
</div>
) : null}
<br />
</Fragment>
);
};
export default CommonDisplay;

View File

@ -1,8 +1,7 @@
import * as Types from '../../graphql/types.generated';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type QuestionFragment = { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } };
import { QuestionFragmentDoc } from '../fragments.generated';
export type FrontpageQueryVariables = Types.Exact<{ [key: string]: never; }>;
@ -15,6 +14,6 @@ export type SearchQueryVariables = Types.Exact<{
export type SearchQuery = { __typename?: 'Query', result: Array<{ __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } }> };
export const QuestionFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Question"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Question"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"probability"}}]}},{"kind":"Field","name":{"kind":"Name","value":"platform"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}},{"kind":"Field","name":{"kind":"Name","value":"qualityIndicators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stars"}},{"kind":"Field","name":{"kind":"Name","value":"numForecasts"}},{"kind":"Field","name":{"kind":"Name","value":"numForecasters"}},{"kind":"Field","name":{"kind":"Name","value":"volume"}},{"kind":"Field","name":{"kind":"Name","value":"spread"}},{"kind":"Field","name":{"kind":"Name","value":"sharesVolume"}},{"kind":"Field","name":{"kind":"Name","value":"openInterest"}},{"kind":"Field","name":{"kind":"Name","value":"liquidity"}},{"kind":"Field","name":{"kind":"Name","value":"tradeVolume"}}]}},{"kind":"Field","name":{"kind":"Name","value":"visualization"}}]}}]} as unknown as DocumentNode<QuestionFragment, unknown>;
export const FrontpageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Frontpage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"frontpage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<FrontpageQuery, FrontpageQueryVariables>;
export const SearchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Search"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SearchInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"searchQuestions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<SearchQuery, SearchQueryVariables>;

View File

@ -1,31 +1,3 @@
fragment Question on Question {
id
url
title
description
timestamp
options {
name
probability
}
platform {
id
label
}
qualityIndicators {
stars
numForecasts
numForecasters
volume
spread
sharesVolume
openInterest
liquidity
tradeVolume
}
visualization
}
query Frontpage {
result: frontpage {
...Question

View File

@ -1,7 +1,7 @@
import { NextPage } from "next";
import { Layout } from "../../common/Layout";
import { Query } from "../../common/Query";
import { Layout } from "../../display/Layout";
import { PlatformsStatusDocument } from "../queries.generated";
const StatusPage: NextPage = () => {

View File

@ -10,3 +10,22 @@ export const getBasePath = () => {
return "http://localhost:3000";
};
export const cleanText = (text: string): string => {
// 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 : "";
textString = textString
.replaceAll("] (", "](")
.replaceAll(") )", "))")
.replaceAll("( [", "([")
.replaceAll(") ,", "),")
.replaceAll("==", "") // Denotes a title in markdown
.replaceAll("Background\n", "")
.replaceAll("Context\n", "")
.replaceAll("--- \n", "- ")
.replaceAll(/\[(.*?)\]\(.*?\)/g, "$1");
textString = textString.slice(0, 1) == "=" ? textString.slice(1) : textString;
//console.log(textString)
return textString;
};

View File

@ -1,72 +0,0 @@
/* Imports */
import axios from "axios";
import { AlgoliaQuestion } from "../../backend/utils/algolia";
/* Definitions */
const urlEndPoint =
"https://m629r9ugsg-dsn.algolia.net/1/indexes/Space_production/query?x-algolia-agent=Algolia%20for%20vanilla%20JavaScript%203.32.1&x-algolia-application-id=M629R9UGSG&x-algolia-api-key=4e893740a2bd467a96c8bfcf95b2809c";
/* Body */
export default async function searchGuesstimate(
query
): Promise<AlgoliaQuestion[]> {
const response = await axios({
url: urlEndPoint,
// credentials: "omit",
headers: {
// "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0",
Accept: "application/json",
"Accept-Language": "en-US,en;q=0.5",
"content-type": "application/x-www-form-urlencoded",
},
data: `{\"params\":\"query=${query.replace(
/ /g,
"%20"
)}&hitsPerPage=20&page=0&getRankingInfo=true\"}`,
method: "POST",
});
const models: any[] = response.data.hits;
const mappedModels: AlgoliaQuestion[] = models.map((model, index) => {
const description = model.description
? model.description.replace(/\n/g, " ").replace(/ /g, " ")
: "";
const stars = description.length > 250 ? 2 : 1;
const q: AlgoliaQuestion = {
id: `guesstimate-${model.id}`,
title: model.name,
url: `https://www.getguesstimate.com/models/${model.id}`,
timestamp: model.created_at, // TODO - check that format matches
platform: "guesstimate",
description,
options: [],
qualityindicators: {
stars,
numforecasts: 1,
numforecasters: 1,
},
extra: {
visualization: model.big_screenshot,
},
// ranking: 10 * (index + 1) - 0.5, //(model._rankingInfo - 1*index)// hack
};
return q;
});
// filter for duplicates. Surprisingly common.
let uniqueTitles = [];
let uniqueModels: AlgoliaQuestion[] = [];
for (let model of mappedModels) {
if (!uniqueTitles.includes(model.title) && !model.title.includes("copy")) {
uniqueModels.push(model);
uniqueTitles.push(model.title);
}
}
console.log(uniqueModels);
return uniqueModels;
}
// searchGuesstimate("COVID-19").then(guesstimateModels => console.log(guesstimateModels))

View File

@ -1,8 +1,7 @@
// import fetch from "fetch"
import axios, { AxiosRequestConfig } from "axios";
export async function uploadToImgur(dataURL, handleGettingImgurlImage) {
let request: AxiosRequestConfig = {
export async function uploadToImgur(dataURL: string): Promise<string> {
const request: AxiosRequestConfig = {
method: "post",
url: "https://api.imgur.com/3/image",
headers: {
@ -12,18 +11,15 @@ export async function uploadToImgur(dataURL, handleGettingImgurlImage) {
type: "base64",
image: dataURL.split(",")[1],
},
// redirect: "follow",
};
let url;
let url = "https://i.imgur.com/qcThRRz.gif"; // Error image
try {
let response = await axios(request).then((response) => response.data);
// console.log(dataURL)
// console.log(response)
const response = await axios(request).then((response) => response.data);
url = `https://i.imgur.com/${response.data.id}.png`;
} catch (error) {
console.log("error", error);
}
let errorImageURL = "https://i.imgur.com/qcThRRz.gif"; // Error image
url = url || errorImageURL;
handleGettingImgurlImage(url);
return url;
}

View File

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