packages/page-referenda/src/Referenda/Referendum.tsx
// Copyright 2017-2024 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ChartOptions, ChartTypeRegistry, TooltipItem } from 'chart.js';
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletReferendaReferendumInfoConvictionVotingTally, PalletReferendaReferendumInfoRankedCollectiveTally, PalletReferendaTrackInfo } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import type { CurveGraph, ReferendumProps as Props } from '../types.js';
import React, { useMemo } from 'react';
import { Chart, Columar, LinkExternal, styled, Table } from '@polkadot/react-components';
import { useBestNumber, useBlockInterval, useToggle } from '@polkadot/react-hooks';
import { calcBlockTime } from '@polkadot/react-hooks/useBlockTime';
import { BN_MILLION, BN_THOUSAND, bnMax, bnToBn, formatNumber, objectSpread } from '@polkadot/util';
import { useTranslation } from '../translate.js';
import Killed from './RefKilled.js';
import Ongoing from './RefOngoing.js';
import Tuple from './RefTuple.js';
const COMPONENTS: Record<string, React.ComponentType<Props>> = {
Killed,
Ongoing
};
const VAL_COLORS = ['#ff8c00', '#9c3333', '#339c33'];
const BOX_COLORS = {
conf: 'rgba(255, 140, 0, 0.1)',
enac: 'rgba(0, 0, 140, 0.1)',
fail: 'rgba(140, 0, 0, 0.02)',
pass: 'rgba(0, 140, 0, 0.02)',
past: 'rgba(140, 140, 140, 0.2)'
};
const PT_CUR = 0;
const PT_NEG = 1;
const PT_POS = 2;
const OPTIONS: ChartOptions = {
aspectRatio: 2.25,
maintainAspectRatio: true,
scales: {
y: {
beginAtZero: true
}
}
};
interface ChartResult {
progress: {
percent: number;
value: BN;
total: BN;
};
labels: string[];
values: number[][];
}
interface ChartResultExt extends ChartResult {
changeX: number;
currentY: number;
endConfirm: BN | null;
points: BN[];
since: BN;
}
interface ChartProps extends ChartResult {
colors: string[];
options: typeof OPTIONS;
}
function createTitleCallback (t: (key: string, options?: { replace: Record<string, unknown> }) => string, bestNumber: BN, blockInterval: BN, extraFn: (blockNumber: BN) => string): (items: TooltipItem<keyof ChartTypeRegistry>[]) => string | string[] {
return ([{ label }]: TooltipItem<keyof ChartTypeRegistry>[]): string | string[] => {
try {
const blockNumber = bnToBn(label.replace(/,/g, ''));
const extraTitle = extraFn(blockNumber);
if (blockNumber.gt(bestNumber)) {
const blocks = blockNumber.sub(bestNumber);
const when = new Date(Date.now() + blocks.mul(blockInterval).toNumber()).toLocaleString();
const calc = calcBlockTime(blockInterval, blocks, t);
const result = [`#${label}`, t('{{when}} (est.)', { replace: { when } }), calc[1]];
if (extraTitle) {
result.push(extraTitle);
}
return result;
}
if (extraTitle) {
return [`#${label}`, extraTitle];
}
} catch {
// ignore
}
return `#${label}`;
};
}
function getChartResult (totalEligible: BN, isConvictionVote: boolean, info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally, track: PalletReferendaTrackInfo, trackGraph: CurveGraph): ChartResultExt[] | null {
if (totalEligible && isConvictionVote && info.isOngoing) {
const ongoing = info.asOngoing;
if (ongoing.deciding.isSome) {
const { approval, support, x } = trackGraph;
const { deciding, tally } = ongoing;
const { confirming, since } = deciding.unwrap();
const endConfirm = confirming.unwrapOr(null);
const currentSupport = isConvictionVote
? (tally as PalletConvictionVotingTally).support
: (tally as PalletRankedCollectiveTally).bareAyes;
const labels: string[] = [];
const values: number[][][] = [[[], [], []], [[], [], []]];
const supc = totalEligible.isZero()
? 0
: currentSupport.mul(BN_THOUSAND).div(totalEligible).toNumber() / 10;
const appc = tally.ayes.isZero()
? 0
: tally.ayes.mul(BN_THOUSAND).div(tally.ayes.add(tally.nays)).toNumber() / 10;
let appx = -1;
let supx = -1;
const points: BN[] = [];
for (let i = 0; i < approval.length; i++) {
labels.push(formatNumber(since.add(x[i])));
points.push(x[i]);
const appr = approval[i].div(BN_MILLION).toNumber() / 10;
const appn = appc < appr;
values[0][PT_CUR][i] = appr;
values[0][appn ? PT_NEG : PT_POS][i] = appc;
appx = (appn || appx !== -1) ? appx : i;
const supr = support[i].div(BN_MILLION).toNumber() / 10;
const supn = supc < supr;
values[1][PT_CUR][i] = supr;
values[1][supn ? PT_NEG : PT_POS][i] = supc;
supx = (supn || supx !== -1) ? supx : i;
}
const step = x[1].sub(x[0]);
const lastIndex = x.length - 1;
const lastBlock = endConfirm?.add(track.minEnactmentPeriod);
// if the confirmation end is later than shown on our graph, we extend it
if (lastBlock?.gt(since.add(x[lastIndex]))) {
let currentBlock = x[lastIndex].add(since).add(step);
do {
labels.push(formatNumber(currentBlock));
points.push(currentBlock.sub(since));
// adjust approvals (no curve adjustment)
// values[0][0].push(values[0][0][lastIndex]);
values[0][1].push(values[0][1][lastIndex]);
values[0][2].push(values[0][2][lastIndex]);
// // adjust support
// values[1][0].push(values[1][0][lastIndex]);
values[1][1].push(values[1][1][lastIndex]);
values[1][2].push(values[1][2][lastIndex]);
currentBlock = currentBlock.add(step);
} while (currentBlock.lt(lastBlock));
}
return [
{ changeX: appx, currentY: appc, endConfirm, labels, points, progress: { percent: appc, total: ongoing.tally.ayes.add(ongoing.tally.nays), value: ongoing.tally.ayes }, since, values: values[0] },
{ changeX: supx, currentY: supc, endConfirm, labels, points, progress: { percent: supc, total: totalEligible, value: currentSupport }, since, values: values[1] }
];
}
}
return null;
}
function getChartProps (bestNumber: BN, blockInterval: BN, chartProps: ChartResultExt[], refId: BN, track: PalletReferendaTrackInfo, t: (key: string, options?: { replace: Record<string, unknown> }) => string): ChartProps[] {
const changeXMax = chartProps.reduce((max, { changeX }) =>
max === -1 || changeX === -1
? -1
: Math.max(max, changeX),
0);
return chartProps.map(({ changeX, currentY, endConfirm, labels, points, progress, since, values }, index): ChartProps => {
const maxX = labels.length;
const maxY = index === 0
? 100
: 50;
const blockToX = (value: BN) =>
Math.max(0, Math.min(maxX, maxX * (
value.sub(since).toNumber() / points[points.length - 1].toNumber()
)));
const swapX = changeX === -1
? -1
: maxX * (changeX / points.length);
const enactX = changeXMax !== -1 && bnMax(bestNumber, points[changeXMax].add(since));
const confirmX = endConfirm
? [endConfirm.sub(track.confirmPeriod), endConfirm, endConfirm.add(track.minEnactmentPeriod)]
: enactX
? [enactX, enactX.add(track.confirmPeriod), enactX.add(track.confirmPeriod).add(track.minEnactmentPeriod)]
: null;
const title = createTitleCallback(t, bestNumber, blockInterval, (blockNumber) =>
confirmX && blockNumber.gte(confirmX[0])
? blockNumber.lte(confirmX[1])
? t('Confirmation period')
: blockNumber.lte(confirmX[2])
? t('Enactment period')
: ''
: ''
);
return {
colors: VAL_COLORS,
labels,
options: objectSpread({
plugins: {
annotation: {
annotations: objectSpread(
{
past: {
backgroundColor: BOX_COLORS.past,
borderWidth: 0,
type: 'box',
xMax: blockToX(bestNumber),
xMin: 0,
yMax: maxY,
yMin: 0
}
},
confirmX
? {
conf: {
backgroundColor: BOX_COLORS.conf,
borderWidth: 0,
type: 'box',
xMax: blockToX(confirmX[1]),
xMin: blockToX(confirmX[0]),
yMax: maxY,
yMin: 0
},
enac: {
backgroundColor: BOX_COLORS.enac,
borderWidth: 0,
type: 'box',
xMax: blockToX(confirmX[2]),
xMin: blockToX(confirmX[1]),
yMax: maxY,
yMin: 0
}
}
: {},
{
fail: {
backgroundColor: BOX_COLORS.fail,
borderWidth: 0,
type: 'box',
xMax: swapX === -1
? maxX
: swapX,
xMin: 0,
yMax: currentY,
yMin: 0
}
},
swapX !== -1
? {
pass: {
backgroundColor: BOX_COLORS.pass,
borderWidth: 0,
type: 'box',
xMax: maxX,
xMin: swapX,
yMax: currentY,
yMin: 0
}
}
: {}
)
},
crosshair: {
sync: {
group: refId.toNumber()
}
},
tooltip: {
callbacks: {
title
}
}
}
}, OPTIONS),
progress,
values
};
});
}
function extractInfo (info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally, track?: PalletReferendaTrackInfo): { confirmEnd: BN | null, enactAt: { at: boolean, blocks: BN, end: BN | null } | null, nextAlarm: null | BN, submittedIn: null | BN } {
let confirmEnd: BN | null = null;
let enactAt: { at: boolean, blocks: BN, end: BN | null } | null = null;
let nextAlarm: BN | null = null;
let submittedIn: BN | null = null;
if (info.isOngoing) {
const { alarm, deciding, enactment, submitted } = info.asOngoing;
enactAt = {
at: enactment.isAt,
blocks: enactment.isAt
? enactment.asAt
: enactment.asAfter,
end: null
};
nextAlarm = alarm.unwrapOr([null])[0];
submittedIn = submitted;
if (deciding.isSome) {
const { confirming } = deciding.unwrap();
if (confirming.isSome) {
// we are confirming with the specific end block
confirmEnd = confirming.unwrap();
if (track) {
// add our track data
const fastEnd = confirmEnd.add(track.minEnactmentPeriod);
enactAt.end = enactment.isAt
? bnMax(fastEnd, enactment.asAt)
: fastEnd.add(enactment.asAfter);
}
}
}
}
return { confirmEnd, enactAt, nextAlarm, submittedIn };
}
function Referendum (props: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const bestNumber = useBestNumber();
const blockInterval = useBlockInterval();
const { activeIssuance, className = '', palletReferenda, value: { id, info, isConvictionVote, track, trackGraph } } = props;
const [isExpanded, toggleExpanded] = useToggle(false);
const Component = useMemo(
() => COMPONENTS[info.type] || Tuple,
[info]
);
const chartResult = useMemo(
() => activeIssuance && track && trackGraph &&
getChartResult(activeIssuance, isConvictionVote, info, track, trackGraph),
[activeIssuance, info, isConvictionVote, track, trackGraph]
);
const chartProps = useMemo(
() => bestNumber && chartResult && isExpanded && track &&
getChartProps(bestNumber, blockInterval, chartResult, id, track, t),
[bestNumber, blockInterval, chartResult, id, isExpanded, t, track]
);
const { confirmEnd, enactAt, nextAlarm, submittedIn } = useMemo(
() => extractInfo(info, track),
[info, track]
);
const chartLegend = useMemo(
() => [
[
t('minimum approval'),
t('current approval (failing)'),
t('current approval (passing)')
],
[
t('minimum support'),
t('current support (failing)'),
t('current support (passing)')
]
],
[t]
);
return (
<>
<StyledTr className={`${className} isExpanded isFirst ${isExpanded ? '' : 'isLast'}`}>
<Table.Column.Id value={id} />
<Component {...props} />
<Table.Column.Expand
isExpanded={isExpanded}
toggle={toggleExpanded}
/>
</StyledTr>
<StyledTr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'}`}>
<td />
<td
className='columar'
colSpan={6}
>
{chartProps && (
<Columar>
<Columar.Column>
<Chart.Line
legends={chartLegend[0]}
title={t('approval / {{percent}}%', { replace: { percent: chartProps[0].progress.percent.toFixed(1) } })}
{...chartProps[0]}
/>
</Columar.Column>
<Columar.Column>
<Chart.Line
legends={chartLegend[1]}
title={t('support / {{percent}}%', { replace: { percent: chartProps[1].progress.percent.toFixed(1) } })}
{...chartProps[1]}
/>
</Columar.Column>
</Columar>
)}
<Columar size='tiny'>
<Columar.Column>
{submittedIn && (
<>
<h5>{t('Submitted at')}</h5>
#{formatNumber(submittedIn)}
</>
)}
{nextAlarm && (
<>
<h5>{t('Next alarm')}</h5>
#{formatNumber(nextAlarm)}
</>
)}
</Columar.Column>
<Columar.Column>
{enactAt && (
<>
<h5>{enactAt.at ? t('Enact at') : t('Enact after')}</h5>
{enactAt.at && '#'}{t('{{blocks}} blocks', { replace: { blocks: formatNumber(enactAt.blocks) } })}
</>
)}
{confirmEnd && (
<>
<h5>{t('Confirm end')}</h5>
#{formatNumber(confirmEnd)}
</>
)}
{enactAt?.end && (
<>
<h5>{t('Enact end')}</h5>
#{formatNumber(enactAt.end)}
</>
)}
</Columar.Column>
</Columar>
<Columar
is100
size='tiny'
>
<Columar.Column>
<LinkExternal
data={id}
type={palletReferenda}
withTitle
/>
</Columar.Column>
</Columar>
</td>
<td />
</StyledTr>
</>
);
}
const StyledTr = styled.tr`
.shortHash {
max-width: var(--width-shorthash);
min-width: 3em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: var(--width-shorthash);
}
`;
export default React.memo(Referendum);