Skip to content

Commit

Permalink
Add a proposal "exploration" page similar to the realms-explorer prop…
Browse files Browse the repository at this point in the history
…osal page (solana-labs#767)

* Add a proposal exploration page

* adjust styling
  • Loading branch information
nramadas authored Jun 18, 2022
1 parent 6568fdb commit e26f839
Show file tree
Hide file tree
Showing 22 changed files with 1,874 additions and 2 deletions.
141 changes: 141 additions & 0 deletions components/ProposalRemainingVotingTime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Governance, ProgramAccount, Proposal } from '@solana/spl-governance'
import classNames from 'classnames'
import dayjs from 'dayjs'
import { useEffect, useState, useRef } from 'react'

import { ntext } from '@utils/ntext'

const diffTime = (ended: boolean, now: dayjs.Dayjs, end: dayjs.Dayjs) => {
if (ended) {
return {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
}
}

const days = end.diff(now, 'day')
const withoutDays = end.subtract(days, 'day')
const hours = withoutDays.diff(now, 'hour')
const withoutHours = withoutDays.subtract(hours, 'hour')
const minutes = withoutHours.diff(now, 'minute')
const withoutMinutes = withoutHours.subtract(minutes, 'minute')
const seconds = withoutMinutes.diff(now, 'second')

return { days, hours, minutes, seconds }
}

const Cell = ({
count,
hideLeadingZeros,
label,
}: {
count: number
hideLeadingZeros?: boolean
label: string
}) => (
<div className="flex flex-col items-center w-10">
<div className="text-3xl font-bold">
{count < 10 &&
(hideLeadingZeros ? <span className="opacity-30">0</span> : '0')}
{count}
</div>
<div className="text-xs text-fgd-3">{ntext(count, label)}</div>
</div>
)

const Divider = () => (
<div
className={classNames(
'flex-col',
'flex',
'font-bold',
'items-center',
'justify-start',
'leading-[1.875rem]',
'opacity-30',
'text-[1.875rem]',
'text-white',
'w-5'
)}
>
:
</div>
)

interface Props {
className?: string
align?: 'left' | 'right'
governance: ProgramAccount<Governance>
proposal: ProgramAccount<Proposal>
}

export default function ProposalRemainingVotingTime(props: Props) {
const voteTime = props.proposal.account.getTimeToVoteEnd(
props.governance.account
)
const votingEnded = props.proposal.account.hasVoteTimeEnded(
props.governance.account
)

const [now, setNow] = useState(dayjs())
const end = useRef(dayjs(1000 * (dayjs().unix() + voteTime)))

useEffect(() => {
const interval = setInterval(() => {
setNow(dayjs())
}, 1000)

return () => clearInterval(interval)
}, [])

const { days, hours, minutes, seconds } = diffTime(
votingEnded,
now,
end.current
)

return (
<div className={props.className}>
<h3 className={classNames(props.align === 'right' && 'text-right')}>
Voting Time Remaining
</h3>
{votingEnded ? (
<div
className={classNames(
'text-3xl',
'font-bold',
'text-fgd-3',
props.align === 'right' && 'text-right'
)}
>
Voting has ended
</div>
) : (
<div
className={classNames(
'flex',
props.align === 'right' && 'justify-end'
)}
>
<Cell hideLeadingZeros count={days} label="day" />
<Divider />
<Cell hideLeadingZeros={!days} count={hours} label="hour" />
<Divider />
<Cell
hideLeadingZeros={!days && !hours}
count={minutes}
label="min"
/>
<Divider />
<Cell
hideLeadingZeros={!days && !hours && !minutes}
count={seconds}
label="sec"
/>
</div>
)}
</div>
)
}
43 changes: 43 additions & 0 deletions components/ProposalSignatories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
ProgramAccount,
Proposal,
SignatoryRecord,
} from '@solana/spl-governance'
import { ExternalLinkIcon } from '@heroicons/react/outline'

import { getExplorerUrl } from '@components/explorer/tools'
import { abbreviateAddress } from '@utils/formatting'

interface Props {
className?: string
endpoint: string
proposal: ProgramAccount<Proposal>
signatories: ProgramAccount<SignatoryRecord>[]
}

export default function ProposalSignatories(props: Props) {
return (
<div className={props.className}>
<h3>
Signatories - {props.proposal.account.signatoriesCount} /{' '}
{props.proposal.account.signatoriesSignedOffCount}
</h3>
<div>
{props.signatories
.filter((s) => s.account.signedOff)
.map((s) => (
<a
className="flex items-center opacity-80 transition-opacity hover:opacity-100 focus:opacity-100"
href={getExplorerUrl(props.endpoint, s.pubkey)}
key={s.pubkey.toBase58()}
target="_blank"
rel="noreferrer"
>
{abbreviateAddress(s.pubkey)}
<ExternalLinkIcon className="flex-shrink-0 h-4 ml-2 w-4 text-primary-light" />
</a>
))}
</div>
</div>
)
}
152 changes: 152 additions & 0 deletions components/ProposalTopVotersBubbleChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from 'react'
import AutoSizer from 'react-virtualized-auto-sizer'
import type { HierarchyCircularNode } from 'd3'

import { VoterDisplayData, VoteType } from '@models/proposal'
import { abbreviateAddress } from '@utils/formatting'
import { getExplorerUrl } from '@components/explorer/tools'

const voteTypeDomain = (type: VoteType) => {
switch (type) {
case VoteType.No:
return 'Nay'
case VoteType.Undecided:
return 'Undecided'
case VoteType.Yes:
return 'Yay'
}
}

const loadD3 = () => import('d3')

type Unpromise<P> = P extends Promise<infer T> ? T : never
type D3 = Unpromise<ReturnType<typeof loadD3>>

interface Props {
className?: string
data: VoterDisplayData[]
endpoint: string
height: number
maxNumBubbles: number
highlighted?: string
width: number
onHighlight?(key?: string): void
}

function Content(props: Props) {
const container = useRef<HTMLDivElement>(null)
const [d3, setD3] = useState<D3 | null>(null)

useEffect(() => {
loadD3().then(setD3)
}, [])

useEffect(() => {
if (container.current && d3 && props.data.length) {
container.current.innerHTML = ''

const color = d3
.scaleOrdinal()
.domain([
voteTypeDomain(VoteType.Undecided),
voteTypeDomain(VoteType.Yes),
voteTypeDomain(VoteType.No),
])
.range(['rgb(82, 82, 82)', 'rgb(101, 163, 13)', 'rgb(159, 18, 57)'])

const hierarchy = d3
.hierarchy({ children: props.data })
.sum((d: any) => (d.votesCast ? d.votesCast.toNumber() : 0))
.sort((a, b) => (b.value || 0) - (a.value || 0))

const pack = d3.pack().size([props.width, props.height]).padding(3)

const root = pack(hierarchy)

const parent = d3
.select(container.current)
.append('svg')
.attr('viewBox', [0, 0, props.width, props.height])
.attr('height', props.height)
.attr('width', props.width)
.attr('font-size', 10)
.attr('font-family', 'sans-serif')
.attr('text-anchor', 'middle')

const data = root
.descendants()
.slice(
1,
props.maxNumBubbles
) as HierarchyCircularNode<VoterDisplayData>[]

const group = parent
.selectAll('g')
.data(data)
.join('g')
.attr('transform', (d) => `translate(${d.x + 1},${d.y + 1})`)
.style('opacity', (d) => (d.data.key === props.highlighted ? 1 : 0.5))
.style('cursor', 'pointer')
.on('mouseenter', function () {
const node = d3
.select(this)
.datum() as HierarchyCircularNode<VoterDisplayData>
props.onHighlight?.(node.data.key)
})
.on('mouseleave', () => {
props.onHighlight?.()
})
.on('click', function () {
const node = d3
.select(this)
.datum() as HierarchyCircularNode<VoterDisplayData>

window.open(getExplorerUrl(props.endpoint, node.data.name), '_blank')
})

// draw circles
group
.append('circle')
.attr('r', (d) => d.r)
.attr('fill', (d) => color(voteTypeDomain(d.data.voteType)) as string)

// add labels
group
.append('svg:text')
.attr('fill', 'white')
.style('pointer-events', 'none')
.style('opacity', (d) => (d.data.key === props.highlighted ? 1 : 0.2))
.style('transform', (d) =>
d.data.key === props.highlighted ? 'scale(1.5)' : 'scale(1)'
)
.attr('y', '0.5em')
.text((d) => abbreviateAddress(d.data.name))
}
}, [
container,
props.data,
props.maxNumBubbles,
d3,
props.height,
props.width,
props.highlighted,
])

return (
<div ref={container} style={{ height: props.height, width: props.width }} />
)
}

export default function ProposalTopVotersBubbleChart(
props: Omit<Props, 'height' | 'width'>
) {
return (
<div className={props.className}>
<AutoSizer>{(sizing) => <Content {...sizing} {...props} />}</AutoSizer>
</div>
)
}

ProposalTopVotersBubbleChart.defaultProps = {
maxNumBubbles: 50,
}
Loading

2 comments on commit e26f839

@vercel
Copy link

@vercel vercel bot commented on e26f839 Jun 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on e26f839 Jun 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

governance-ui – ./

governance-ui-git-main-test123z.vercel.app
governance-ui-test123z.vercel.app

Please sign in to comment.