diff --git a/package.json b/package.json
index ef3bbf9..91ab4c8 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"@urql/core": "^4.1.4",
"@urql/exchange-graphcache": "^6.3.3",
"algoliasearch": "4.17.1",
+ "chart.js": "^4.5.0",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"fetch-ponyfill": "^7.1.0",
@@ -58,6 +59,7 @@
"next-themes": "^0.2.1",
"next-usequerystate": "^1.10.2",
"react": "^18",
+ "react-chartjs-2": "^5.3.0",
"react-dom": "^18",
"react-hook-form": "^7.47.0",
"react-json-view": "^1.21.3",
diff --git a/src/app/api/v1/curves/route.ts b/src/app/api/v1/curves/route.ts
new file mode 100644
index 0000000..7479a45
--- /dev/null
+++ b/src/app/api/v1/curves/route.ts
@@ -0,0 +1,38 @@
+// Copyright 2019-2025 @polkassembly/fellowship authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import { NextRequest, NextResponse } from 'next/server';
+import { GET_CURVE_DATA_BY_INDEX } from '@/app/api/v1/subsquidQueries';
+import { urqlClient } from '@/services/urqlClient';
+import getNetworkFromHeaders from '@/app/api/api-utils/getNetworkFromHeaders';
+import getReqBody from '@/app/api/api-utils/getReqBody';
+import withErrorHandling from '@/app/api/api-utils/withErrorHandling';
+import { headers } from 'next/headers';
+import MESSAGES from '@/global/messages';
+import { APIError } from '@/global/exceptions';
+import { API_ERROR_CODE } from '@/global/constants/errorCodes';
+
+export const POST = withErrorHandling(async (req: NextRequest) => {
+ const { blockGte, postId } = await getReqBody(req);
+
+ if (!blockGte || !postId || isNaN(postId) || isNaN(blockGte)) throw new APIError(`${MESSAGES.INVALID_PARAMS_ERROR}`, 500, API_ERROR_CODE.INVALID_PARAMS_ERROR);
+
+ const headersList = headers();
+ const network = getNetworkFromHeaders(headersList);
+
+ const gqlClient = urqlClient(network);
+
+ const variables = {
+ block_gte: Number(blockGte),
+ index_eq: Number(postId)
+ };
+
+ const { data, error } = await gqlClient.query(GET_CURVE_DATA_BY_INDEX, variables).toPromise();
+
+ if (error) throw new APIError(`${error || MESSAGES.SUBSQUID_FETCH_ERROR}`, 500, API_ERROR_CODE.SUBSQUID_FETCH_ERROR);
+
+ const curveData = data?.curveData || [];
+
+ return NextResponse.json(curveData);
+});
diff --git a/src/app/api/v1/subsquidQueries.ts b/src/app/api/v1/subsquidQueries.ts
index 02875c8..032ddc7 100644
--- a/src/app/api/v1/subsquidQueries.ts
+++ b/src/app/api/v1/subsquidQueries.ts
@@ -473,3 +473,16 @@ export const GET_RANK_ACTIVITY = gql`
}
}
`;
+
+export const GET_CURVE_DATA_BY_INDEX = gql`
+ query CurveDataByIndex($index_eq: Int, $block_gte: Int, $limit: Int = 1000) {
+ curveData(limit: $limit, where: { index_eq: $index_eq, block_gte: $block_gte }, orderBy: block_ASC) {
+ approvalPercent
+ block
+ id
+ index
+ supportPercent
+ timestamp
+ }
+ }
+`;
diff --git a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx
new file mode 100644
index 0000000..d860333
--- /dev/null
+++ b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx
@@ -0,0 +1,297 @@
+// Copyright 2019-2025 @polkassembly/fellowship authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import { useEffect, useMemo } from 'react';
+import { Line } from 'react-chartjs-2';
+import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, ChartOptions, ChartData, Point } from 'chart.js';
+import { ProposalType, IStatusHistoryItem, IVoteCurve } from '@/global/types';
+import dayjs from '@/services/dayjs-init';
+import { Network } from '@/global/types';
+import { getTrackFunctions } from '@/utils/trackCurvesUtils';
+
+ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
+
+interface Props {
+ voteCurveData: IVoteCurve[];
+ trackName: ProposalType;
+ timeline?: IStatusHistoryItem[];
+ createdAt?: Date;
+ setThresholdValues?: (values: { approvalThreshold: number; supportThreshold: number }) => void;
+}
+
+function formatHoursAndDays(num: number, unit: 'day' | 'hr') {
+ if (num === 1) {
+ return `${num}${unit}`;
+ }
+ return `${num}${unit}s`;
+}
+
+function convertGraphPoint(value?: number) {
+ if (!value) {
+ return '--';
+ }
+
+ return `${Number(value).toFixed(2)}%`;
+}
+
+function VoteCurves({ voteCurveData, trackName, timeline, createdAt, setThresholdValues }: Props) {
+ const network: Network = 'collectives' as Network;
+
+ const { approvalCalc, supportCalc } = getTrackFunctions({ network, trackName });
+
+ const chartData: ChartData<'line', (number | Point | null)[]> = useMemo(() => {
+ if (!voteCurveData || voteCurveData.length === 0) {
+ return {
+ datasets: [],
+ labels: []
+ };
+ }
+
+ // Simplified track info for fellowship referendums
+ const trackInfo = {
+ decisionPeriod: 100800 // 7 days in blocks
+ };
+
+ const labels: number[] = [];
+ const supportData: { x: number; y: number }[] = [];
+ const approvalData: { x: number; y: number }[] = [];
+
+ const approvalThresholdData: { x: number; y: number }[] = [];
+ const supportThresholdData: { x: number; y: number }[] = [];
+
+ const statusBlock = timeline?.find((s) => s?.status === 'Deciding');
+
+ const lastGraphPoint = voteCurveData[voteCurveData.length - 1];
+ const proposalCreatedAt = dayjs(statusBlock?.timestamp || createdAt || voteCurveData[0].timestamp);
+
+ const { decisionPeriod } = trackInfo;
+
+ const decisionPeriodInHrs = Math.floor(dayjs.duration(decisionPeriod * 6, 'seconds').asHours()); // Assuming 6s block time
+ const decisionPeriodFromTimelineInHrs = dayjs(lastGraphPoint.timestamp).diff(proposalCreatedAt, 'hour');
+
+ if (decisionPeriodFromTimelineInHrs < decisionPeriodInHrs) {
+ for (let i = 0; i < decisionPeriodInHrs; i += 1) {
+ labels.push(i);
+
+ if (approvalCalc) {
+ approvalThresholdData.push({
+ x: i,
+ y: approvalCalc(i / decisionPeriodInHrs) * 100
+ });
+ }
+
+ if (supportCalc) {
+ supportThresholdData.push({
+ x: i,
+ y: supportCalc(i / decisionPeriodInHrs) * 100
+ });
+ }
+ }
+ }
+
+ // Process each data point
+ voteCurveData.forEach((point) => {
+ const hour = dayjs(point.timestamp).diff(proposalCreatedAt, 'hour');
+ labels.push(hour);
+
+ if (decisionPeriodFromTimelineInHrs > decisionPeriodInHrs) {
+ if (approvalCalc) {
+ approvalThresholdData.push({
+ x: hour,
+ y: approvalCalc(hour / decisionPeriodFromTimelineInHrs) * 100
+ });
+ }
+ if (supportCalc) {
+ supportThresholdData.push({
+ x: hour,
+ y: supportCalc(hour / decisionPeriodFromTimelineInHrs) * 100
+ });
+ }
+ }
+
+ // Add actual data points
+ if (point.supportPercent !== undefined) {
+ supportData.push({
+ x: hour,
+ y: point.supportPercent
+ });
+ }
+
+ if (point.approvalPercent !== undefined) {
+ approvalData.push({
+ x: hour,
+ y: point.approvalPercent
+ });
+ }
+ });
+
+ return {
+ datasets: [
+ {
+ backgroundColor: 'transparent',
+ borderColor: '#2ED47A', // voteAye color
+ borderWidth: 2,
+ borderDash: [4, 4],
+ data: approvalData,
+ label: 'Approval',
+ pointHitRadius: 10,
+ pointHoverRadius: 5,
+ pointRadius: 0,
+ tension: 0.1
+ },
+ {
+ backgroundColor: 'transparent',
+ borderColor: '#FF3C5F', // voteNay color
+ borderWidth: 2,
+ borderDash: [4, 4],
+ data: supportData,
+ label: 'Support',
+ pointHitRadius: 10,
+ pointHoverRadius: 5,
+ pointRadius: 0,
+ tension: 0.1
+ },
+ {
+ backgroundColor: 'transparent',
+ borderColor: '#2ED47A', // voteAye color
+ borderWidth: 2,
+ data: approvalThresholdData,
+ label: 'Approval Threshold',
+ pointHitRadius: 10,
+ pointHoverRadius: 5,
+ pointRadius: 0,
+ tension: 0.1
+ },
+ {
+ backgroundColor: 'transparent',
+ borderColor: '#FF3C5F', // voteNay color
+ borderWidth: 2,
+ data: supportThresholdData,
+ label: 'Support Threshold',
+ pointHitRadius: 10,
+ pointHoverRadius: 5,
+ pointRadius: 0,
+ tension: 0.1
+ }
+ ],
+ labels
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [voteCurveData]);
+
+ useEffect(() => {
+ if (!chartData || !chartData.datasets || chartData.datasets.length < 4) {
+ return;
+ }
+
+ const approvalThresholdData = chartData.datasets[2].data as Point[];
+ const supportThresholdData = chartData.datasets[3].data as Point[];
+ const currentApproval = chartData.datasets[0].data[chartData.datasets[0].data.length - 1] as Point;
+ const currentSupport = chartData.datasets[1].data[chartData.datasets[1].data.length - 1] as Point;
+
+ setThresholdValues?.({
+ approvalThreshold: approvalThresholdData.find((data) => data && data?.x >= currentApproval?.x)?.y || 0,
+ supportThreshold: supportThresholdData.find((data) => data && data?.x >= currentSupport?.x)?.y || 0
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [chartData]);
+
+ const chartOptions: ChartOptions<'line'> = {
+ animation: {
+ duration: 0
+ },
+ clip: false,
+ plugins: {
+ legend: {
+ display: false,
+ position: 'bottom'
+ },
+ tooltip: {
+ callbacks: {
+ label(tooltipItem) {
+ const { dataIndex, parsed, dataset } = tooltipItem;
+ if (dataset.label === 'Support Threshold') {
+ const threshold = Number(parsed.y).toFixed(2);
+ const data = chartData.datasets.find((d) => d.label === 'Support');
+
+ const currSupport = data?.data.find((d) => (d as Point).x > dataIndex) as Point;
+ return `Support: ${convertGraphPoint(currSupport?.y)} / ${threshold}%`;
+ }
+ if (dataset.label === 'Approval Threshold') {
+ const threshold = Number(parsed.y).toFixed(2);
+ const data = chartData.datasets.find((d) => d.label === 'Approval');
+
+ const currApproval = data?.data.find((d) => (d as Point).x > dataIndex) as Point;
+ return `Approval: ${convertGraphPoint(currApproval?.y)} / ${threshold}%`;
+ }
+
+ return '';
+ },
+ title(values) {
+ const { label } = values[0];
+ const hours = Number(label);
+ const days = Math.floor(hours / 24);
+ const resultHours = hours - days * 24;
+ let result = `Time: ${formatHoursAndDays(hours, 'hr')}`;
+ if (days > 0) {
+ result += ` (${formatHoursAndDays(days, 'day')} ${resultHours > 0 ? formatHoursAndDays(resultHours, 'hr') : ''})`;
+ }
+ return result;
+ }
+ },
+ displayColors: false,
+ intersect: false,
+ mode: 'index'
+ }
+ },
+ scales: {
+ x: {
+ min: 0,
+ title: {
+ display: true,
+ text: 'Days'
+ },
+ grid: {
+ display: false
+ },
+ ticks: {
+ callback(v) {
+ return (Number(v) / 24).toFixed(0);
+ },
+ stepSize: 24
+ },
+ type: 'linear'
+ },
+ y: {
+ title: {
+ display: false,
+ text: 'Passing Percentage'
+ },
+ max: 100,
+ min: 0,
+ ticks: {
+ stepSize: 10,
+ callback(val) {
+ return `${val}%`;
+ }
+ },
+ grid: {
+ display: false
+ }
+ }
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default VoteCurves;
diff --git a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesData.tsx b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesData.tsx
new file mode 100644
index 0000000..d4cca00
--- /dev/null
+++ b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesData.tsx
@@ -0,0 +1,56 @@
+// Copyright 2019-2025 @polkassembly/fellowship authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import { ProposalType, IStatusHistoryItem, IVoteCurve } from '@/global/types';
+import VoteCurves from './VoteCurves';
+import LoadingSpinner from '@/components/Misc/LoadingSpinner';
+import VoteCurvesDetails from './VoteCurvesDetails';
+import { Card } from '@nextui-org/card';
+
+interface Props {
+ trackName: ProposalType;
+ createdAt?: Date;
+ timeline?: IStatusHistoryItem[];
+ setThresholdValues?: (values: { approvalThreshold: number; supportThreshold: number }) => void;
+ thresholdValues?: { approvalThreshold: number; supportThreshold: number } | undefined;
+ latestApproval: number | null;
+ latestSupport: number | null;
+ isFetching: boolean;
+ voteCurveData: IVoteCurve[];
+}
+
+// main component
+function VoteCurvesData({ trackName, createdAt, timeline, setThresholdValues, thresholdValues, latestApproval, latestSupport, isFetching, voteCurveData }: Props) {
+ return (
+
+ Vote Curves
+
+
+ {isFetching && (
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+export default VoteCurvesData;
diff --git a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDataWrapper.tsx b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDataWrapper.tsx
new file mode 100644
index 0000000..cb20f07
--- /dev/null
+++ b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDataWrapper.tsx
@@ -0,0 +1,96 @@
+// Copyright 2019-2025 @polkassembly/fellowship authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import React, { useState, useEffect } from 'react';
+import { usePostDataContext } from '@/contexts';
+import { IVoteCurve } from '@/global/types';
+import VoteCurvesData from './VoteCurvesData';
+import nextApiClientFetch from '@/utils/nextApiClientFetch';
+import { useApiContext } from '@/contexts';
+
+function VoteCurvesDataWrapper() {
+ const [thresholdValues, setThresholdValues] = useState<{ approvalThreshold: number; supportThreshold: number } | undefined>(undefined);
+ const [voteCurveData, setVoteCurveData] = useState([]);
+ const [isFetching, setIsFetching] = useState(false);
+ const [latestApproval, setLatestApproval] = useState(null);
+ const [latestSupport, setLatestSupport] = useState(null);
+
+ const { postData } = usePostDataContext();
+ const { network } = useApiContext();
+
+ const timeline = postData?.on_chain_info?.statusHistory;
+
+ // Get the block when the proposal started deciding from timeline
+ const getDecidingBlock = () => {
+ if (!timeline || timeline.length === 0) return 0;
+
+ const decidingStatus = timeline.find((status) => status.status === 'Deciding');
+ return decidingStatus?.block || 0;
+ };
+
+ // Fetch vote curves data
+ const fetchVoteCurves = async () => {
+ if (!postData.id) return;
+
+ setIsFetching(true);
+ try {
+ const blockGte = getDecidingBlock();
+
+ const { data, error } = await nextApiClientFetch({
+ url: 'api/v1/curves',
+ data: {
+ blockGte,
+ postId: postData.id
+ },
+ network,
+ isPolkassemblyAPI: false
+ });
+
+ if (error || !data) {
+ console.error('Failed to fetch vote curves:', error);
+ setVoteCurveData([]);
+ return;
+ }
+
+ setVoteCurveData(data);
+
+ // Get latest approval and support values
+ if (data.length > 0) {
+ const latest = data[data.length - 1];
+ setLatestApproval(latest.approvalPercent);
+ setLatestSupport(latest.supportPercent);
+ }
+ } catch (error) {
+ console.error('Error fetching vote curves:', error);
+ setVoteCurveData([]);
+ } finally {
+ setIsFetching(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchVoteCurves();
+ }, [postData.id, timeline]);
+
+ // Only render if we have vote curve data
+ if (voteCurveData.length === 0 && !isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export default VoteCurvesDataWrapper;
diff --git a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDetails.tsx b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDetails.tsx
new file mode 100644
index 0000000..0ca888f
--- /dev/null
+++ b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDetails.tsx
@@ -0,0 +1,50 @@
+// Copyright 2019-2025 @polkassembly/fellowship authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+interface Props {
+ latestApproval: number | null;
+ latestSupport: number | null;
+ thresholdValues: { approvalThreshold: number; supportThreshold: number } | undefined;
+}
+
+function VoteCurvesDetails({ latestApproval, latestSupport, thresholdValues }: Props) {
+ return (
+
+
+
+
+
+ Approval
+
+ {latestApproval?.toFixed(2)}%
+
+
+
+
+ Threshold
+
+ {thresholdValues ? `${thresholdValues.approvalThreshold.toFixed(2)}%` : 'N/A'}
+
+
+
+
+
+
+ Support
+
+ {latestSupport?.toFixed(2)}%
+
+
+
+
+ Threshold
+
+ {thresholdValues ? `${thresholdValues.supportThreshold.toFixed(2)}%` : 'N/A'}
+
+
+
+ );
+}
+
+export default VoteCurvesDetails;
diff --git a/src/components/Post/GovernanceSidebar/index.tsx b/src/components/Post/GovernanceSidebar/index.tsx
index fc76813..c90d95d 100644
--- a/src/components/Post/GovernanceSidebar/index.tsx
+++ b/src/components/Post/GovernanceSidebar/index.tsx
@@ -6,12 +6,15 @@ import React from 'react';
import CastVoteCard from './CastVoteCard';
import VoteInfoCard from './VoteInfoCard';
import DecisionStatusCard from './DecisionStatusCard';
+import VoteCurvesDataWrapper from './VoteCurvesData/VoteCurvesDataWrapper';
+
function GovernanceSidebar() {
return (
);
}
diff --git a/src/global/networkConstants.ts b/src/global/networkConstants.ts
index 98a0ecd..2036dcb 100644
--- a/src/global/networkConstants.ts
+++ b/src/global/networkConstants.ts
@@ -13,7 +13,7 @@ const networkConstants: NetworkConstants = {
category: 'polkadot',
logoUrl: '/parachain-logos/polkadot-logo.svg',
ss58Format: 0,
- subsquidUrl: 'https://squid.subsquid.io/collectives-polkassembly/graphql',
+ subsquidUrl: 'https://polkassembly.squids.live/collectives-polkassembly@v3/api/graphql',
tokenDecimals: 10,
tokenSymbol: 'DOT',
subscanBaseUrl: 'https://kusama.api.subscan.io',
diff --git a/src/global/types.ts b/src/global/types.ts
index 40eeb61..bbf8790 100644
--- a/src/global/types.ts
+++ b/src/global/types.ts
@@ -794,3 +794,28 @@ export interface IRecordingListingResponse {
totalCount: number;
recordings: IRecording[];
}
+
+export interface IVoteCurve {
+ approvalPercent: number;
+ supportPercent: number;
+ block: number;
+ timestamp: string;
+ id: string;
+ index: number;
+}
+
+export interface IStatusHistoryItem {
+ block: number;
+ id: string;
+ status: ProposalStatus;
+ timestamp?: string;
+}
+
+export enum EPostOrigin {
+ FELLOWSHIP_REFERENDUMS = 'fellowship_referendums'
+}
+
+export enum EProposalStatus {
+ Deciding = 'Deciding',
+ DecisionDepositPlaced = 'DecisionDepositPlaced'
+}
diff --git a/src/utils/trackCurvesUtils.ts b/src/utils/trackCurvesUtils.ts
new file mode 100644
index 0000000..efe8693
--- /dev/null
+++ b/src/utils/trackCurvesUtils.ts
@@ -0,0 +1,103 @@
+// Copyright 2019-2025 @polkassembly/fellowship authors & contributors
+// This software may be modified and distributed under the terms
+// of the Apache-2.0 license. See the LICENSE file for details.
+
+import BigNumber from 'bignumber.js';
+import { Network, ProposalType } from '@/global/types';
+
+// Simplified network details for track functions
+const NETWORKS_DETAILS: Record = {
+ collectives: {
+ trackDetails: {
+ fellowship_referendums: {
+ minApproval: {
+ reciprocal: {
+ factor: 1000000000,
+ xOffset: 1000000000,
+ yOffset: 0
+ }
+ },
+ minSupport: {
+ reciprocal: {
+ factor: 1000000000,
+ xOffset: 1000000000,
+ yOffset: 0
+ }
+ },
+ decisionPeriod: 100800 // 7 days in blocks (assuming 6s block time)
+ }
+ }
+ }
+};
+
+export function makeReciprocalCurve(reciprocal: { factor: number; xOffset: number; yOffset: number }) {
+ if (!reciprocal) {
+ return null;
+ }
+ const { factor, xOffset, yOffset } = reciprocal;
+ return function fn(percentage: number) {
+ const x = percentage * 10 ** 9;
+
+ const v = new BigNumber(factor)
+ .div(new BigNumber(x).plus(xOffset))
+ .multipliedBy(10 ** 9)
+ .toFixed(0, BigNumber.ROUND_DOWN);
+
+ const calcValue = new BigNumber(v)
+ .plus(yOffset)
+ .div(10 ** 9)
+ .toString();
+ return BigNumber.max(calcValue, 0).toNumber();
+ };
+}
+
+export function makeLinearCurve(linearDecreasing: { length: number; floor: number; ceil: number }) {
+ if (!linearDecreasing) {
+ return null;
+ }
+ const { length, floor, ceil } = linearDecreasing;
+ return function fn(percentage: number) {
+ const x = percentage * 10 ** 9;
+
+ const xValue = BigNumber.min(x, length);
+ const slope = new BigNumber(ceil).minus(floor).dividedBy(length);
+ const deducted = slope.multipliedBy(xValue).toString();
+
+ const perbill = new BigNumber(ceil).minus(deducted).toFixed(0, BigNumber.ROUND_DOWN);
+ const calcValue = new BigNumber(perbill).div(10 ** 9).toString();
+ return BigNumber.max(calcValue, 0).toNumber();
+ };
+}
+
+export function getTrackFunctions({ network, trackName }: { network: Network; trackName: ProposalType }) {
+ const trackInfo = NETWORKS_DETAILS[network]?.trackDetails?.[trackName];
+ if (!trackInfo) {
+ return {
+ approvalCalc: null,
+ supportCalc: null
+ };
+ }
+
+ let supportCalc = null;
+ let approvalCalc = null;
+ if (trackInfo) {
+ if (trackInfo.minApproval) {
+ if (trackInfo.minApproval.reciprocal) {
+ approvalCalc = makeReciprocalCurve(trackInfo.minApproval.reciprocal);
+ } else if (trackInfo.minApproval.linearDecreasing) {
+ approvalCalc = makeLinearCurve(trackInfo.minApproval.linearDecreasing);
+ }
+ }
+ if (trackInfo.minSupport) {
+ if (trackInfo.minSupport.reciprocal) {
+ supportCalc = makeReciprocalCurve(trackInfo.minSupport.reciprocal);
+ } else if (trackInfo.minSupport.linearDecreasing) {
+ supportCalc = makeLinearCurve(trackInfo.minSupport.linearDecreasing);
+ }
+ }
+ }
+ return {
+ approvalCalc,
+ supportCalc
+ };
+}
diff --git a/yarn.lock b/yarn.lock
index f84ade8..d100457 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -478,6 +478,11 @@
dependencies:
lodash "^4.17.21"
+"@kurkle/color@^0.3.0":
+ version "0.3.4"
+ resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
+ integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==
+
"@next/env@14.2.20":
version "14.2.20"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.20.tgz#0be2cc955f4eb837516e7d7382284cd5bc1d5a02"
@@ -3537,6 +3542,13 @@ character-reference-invalid@^2.0.0:
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9"
integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
+chart.js@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.0.tgz#11a1ef6c4befc514b1b0b613ebac226c4ad2740b"
+ integrity sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==
+ dependencies:
+ "@kurkle/color" "^0.3.0"
+
chokidar@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
@@ -7072,6 +7084,11 @@ react-base16-styling@^0.6.0:
lodash.flow "^3.3.0"
pure-color "^1.2.0"
+react-chartjs-2@^5.3.0:
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz#2d3286339a742bc7f77b5829c33ebab215f714cc"
+ integrity sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==
+
react-copy-to-clipboard@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz#09aae5ec4c62750ccb2e6421a58725eabc41255c"