Merge pull request #74 from quantified-uncertainty/questions-page-2
Question pages and charts
This commit is contained in:
commit
44fbd0cd2b
6
.eslintrc
Normal file
6
.eslintrc
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": ["next", "prettier"],
|
||||
"rules": {
|
||||
"next/no-document-import-in-page": "off"
|
||||
}
|
||||
}
|
1131
package-lock.json
generated
1131
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||
|
|
BIN
public/screenshots/capture.png
Normal file
BIN
public/screenshots/capture.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 MiB |
BIN
public/screenshots/dashboard.png
Normal file
BIN
public/screenshots/dashboard.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 426 KiB |
BIN
public/screenshots/frontpage.png
Normal file
BIN
public/screenshots/frontpage.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 452 KiB |
BIN
public/screenshots/twitter.png
Normal file
BIN
public/screenshots/twitter.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 594 KiB |
|
@ -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
|
||||
`;
|
||||
|
||||
|
|
103
src/backend/platforms/guesstimate.ts
Normal file
103
src/backend/platforms/guesstimate.ts
Normal 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,
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
19
src/web/common/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
35
src/web/common/CopyParagraph.tsx
Normal file
35
src/web/common/CopyParagraph.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
|
@ -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<{
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
|
@ -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>
|
||||
);
|
||||
}
|
9
src/web/fragments.generated.tsx
Normal file
9
src/web/fragments.generated.tsx
Normal 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
38
src/web/fragments.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
155
src/web/questions/components/CaptureQuestion.tsx
Normal file
155
src/web/questions/components/CaptureQuestion.tsx
Normal 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.
|
167
src/web/questions/components/HistoryChart/InnerChart.tsx
Normal file
167
src/web/questions/components/HistoryChart/InnerChart.tsx
Normal 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", "Seravek", "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>
|
||||
);
|
||||
};
|
|
@ -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%" }}
|
||||
/>
|
||||
);
|
||||
};
|
85
src/web/questions/components/HistoryChart/Legend.tsx
Normal file
85
src/web/questions/components/HistoryChart/Legend.tsx
Normal 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>
|
||||
);
|
||||
};
|
36
src/web/questions/components/HistoryChart/index.tsx
Normal file
36
src/web/questions/components/HistoryChart/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
113
src/web/questions/components/HistoryChart/utils.ts
Normal file
113
src/web/questions/components/HistoryChart/utils.ts
Normal 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,
|
||||
};
|
||||
};
|
69
src/web/questions/components/IndicatorsTable.tsx
Normal file
69
src/web/questions/components/IndicatorsTable.tsx
Normal 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>
|
||||
);
|
177
src/web/questions/components/QuestionCard/QuestionFooter.tsx
Normal file
177
src/web/questions/components/QuestionCard/QuestionFooter.tsx
Normal 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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
|
@ -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
|
|
@ -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}
|
|
@ -1,4 +1,4 @@
|
|||
import { QuestionFragment } from "../../search/queries.generated";
|
||||
import { QuestionFragment } from "../../fragments.generated";
|
||||
|
||||
type QualityIndicator = QuestionFragment["qualityIndicators"];
|
||||
type IndicatorName = keyof QualityIndicator;
|
|
@ -1,4 +1,4 @@
|
|||
import { QuestionFragment } from "../../search/queries.generated";
|
||||
import { QuestionFragment } from "../../fragments.generated";
|
||||
import { formatProbability } from "../utils";
|
||||
|
||||
type Option = QuestionFragment["options"][0];
|
||||
|
|
62
src/web/questions/components/Stars.tsx
Normal file
62
src/web/questions/components/Stars.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>;
|
|
@ -1,5 +1,5 @@
|
|||
query QuestionById($id: ID!) {
|
||||
query QuestionPage($id: ID!) {
|
||||
result: question(id: $id) {
|
||||
...Question
|
||||
...QuestionWithHistory
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,5 +1,3 @@
|
|||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
|
@ -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;
|
|
@ -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>;
|
|
@ -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
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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))
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,9 @@ module.exports = {
|
|||
backgroundImage: {
|
||||
quri: "url('/icons/logo.svg')",
|
||||
},
|
||||
maxWidth: {
|
||||
160: "160px",
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
|
|
Loading…
Reference in New Issue
Block a user