diff --git a/src/components/Charts/ProposalVPChart.css b/src/components/Charts/ProposalVPChart.css index e75b4d4e6..74fbaa320 100644 --- a/src/components/Charts/ProposalVPChart.css +++ b/src/components/Charts/ProposalVPChart.css @@ -1,16 +1,49 @@ #ProposalVPChartTooltip { - background: rgba(0, 0, 0, 0.7); - border-radius: 3px; - color: white; + --padding-size: 9px; + display: flex; + flex-direction: row; + align-items: center; + background: rgba(22, 20, 26, 0.9); + border-radius: 5px; + color: var(--white-900); opacity: 1; pointer-events: none; position: absolute; transform: translate(-50%, 0); transition: all 0.1s ease; width: max-content; - padding: 4px; + padding: var(--padding-size); + z-index: 100; +} + +#ProposalVPChartTooltip .avatar { + --size: 40px; + min-width: var(--size) !important; + max-width: var(--size) !important; + min-height: var(--size) !important; + max-height: var(--size) !important; + border-radius: 100%; + background-color: #7a7a7a; + vertical-align: middle; + margin: 0; } #ProposalVPChartTooltip .container { margin: 0; + margin-left: var(--padding-size); +} + +#ProposalVPChartTooltip .title { + font-size: 13px; + font-style: normal; + line-height: 18px; + max-width: 160px; +} + +#ProposalVPChartTooltip .details { + font-size: 10px; + font-style: normal; + font-weight: 400; + line-height: 18px; + opacity: 0.6; } diff --git a/src/components/Charts/ProposalVPChart.tsx b/src/components/Charts/ProposalVPChart.tsx index 0d8afb53d..e629487cf 100644 --- a/src/components/Charts/ProposalVPChart.tsx +++ b/src/components/Charts/ProposalVPChart.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Chart } from 'react-chartjs-2' -import { ChartData, Chart as ChartJS } from 'chart.js' +import { ChartData, Chart as ChartJS, ScriptableTooltipContext } from 'chart.js' import 'chart.js/auto' import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm' import annotationPlugin from 'chartjs-plugin-annotation' @@ -9,6 +9,8 @@ import annotationPlugin from 'chartjs-plugin-annotation' import { Vote } from '../../entities/Votes/types' import useAbbreviatedFormatter from '../../hooks/useAbbreviatedFormatter' import useFormatMessage from '../../hooks/useFormatMessage' +import useProfiles from '../../hooks/useProfiles' +import { Avatar } from '../../utils/Catalyst/types' import Section from '../Proposal/View/Section' import './ProposalVPChart.css' @@ -27,6 +29,7 @@ ChartJS.register(annotationPlugin) interface Props { requiredToPass?: number | null voteMap: Record + isLoadingVotes?: boolean } const COMMON_DATASET_OPTIONS = { @@ -36,19 +39,33 @@ const COMMON_DATASET_OPTIONS = { pointHoverBorderWidth: 8, } -function ProposalVPChart({ requiredToPass, voteMap }: Props) { +function ProposalVPChart({ requiredToPass, voteMap, isLoadingVotes }: Props) { const t = useFormatMessage() const YAxisFormat = useAbbreviatedFormatter() const chartRef = useRef(null) const votes = useMemo(() => getSortedVotes(voteMap), [voteMap]) - const { yesVotes, noVotes, abstainVotes } = useMemo(() => getSegregatedVotes(votes), [votes]) - + const { profiles, isLoadingProfiles } = useProfiles(votes.map((vote) => vote.address)) + const profileByAddress = useMemo( + () => + profiles.reduce((acc, { profile }) => { + acc.set(profile.ethAddress.toLowerCase(), profile) + return acc + }, new Map()), + [profiles] + ) + const { yesVotes, noVotes, abstainVotes } = useMemo( + () => getSegregatedVotes(votes, profileByAddress), + [profileByAddress, votes] + ) const segregatedVotesMap = { Yes: yesVotes, No: noVotes, Abstain: abstainVotes, } + const tooltipTitle = (choice: string, vp: number) => + t('page.proposal_view.votes_chart.tooltip_title', { choice, vp: vp.toLocaleString('en-US') }) + const [chartData, setChartData] = useState>({ datasets: [] }) useEffect(() => { setChartData({ @@ -93,24 +110,10 @@ function ProposalVPChart({ requiredToPass, voteMap }: Props) { }, }, tooltip: { - // displayColors: false, - // callbacks: { - // label: (context: TooltipItem<'line'>) => { - // const vp = context.formattedValue - // const choice = context.dataset.label - // return t('page.proposal_view.votes_chart.tooltip_label', { choice, vp }) - // }, - // afterLabel: (context: TooltipItem<'line'>) => { - // const idx = context.dataIndex - // const choice = context.dataset.label as keyof typeof segregatedVotesMap - // const vote = segregatedVotesMap[choice][idx] - // const formattedVP = vote.vp.toLocaleString('en-US') - // return t('page.proposal_view.votes_chart.tooltip_voter', { address: vote.address, vp: formattedVP }) - // }, - // }, enabled: false, position: 'nearest' as const, - external: externalTooltipHandler, + external: (context: ScriptableTooltipContext<'line'>) => + externalTooltipHandler({ context, datasetMap: segregatedVotesMap, title: tooltipTitle }), }, annotation: { annotations: { @@ -165,7 +168,7 @@ function ProposalVPChart({ requiredToPass, voteMap }: Props) { } return ( -
+
) diff --git a/src/components/Charts/ProposalVPChart.utils.ts b/src/components/Charts/ProposalVPChart.utils.ts index b1cfda686..19ceb907e 100644 --- a/src/components/Charts/ProposalVPChart.utils.ts +++ b/src/components/Charts/ProposalVPChart.utils.ts @@ -1,8 +1,12 @@ import type { Chart, ScriptableTooltipContext } from 'chart.js' import { Vote } from '../../entities/Votes/types' +import { DEFAULT_AVATAR_IMAGE } from '../../utils/Catalyst' +import { Avatar } from '../../utils/Catalyst/types' +import Time from '../../utils/date/Time' type VoteWithAddress = Vote & { address: string } +type VoteWithProfile = VoteWithAddress & { profile?: Avatar } const TOOLTIP_ID = 'ProposalVPChartTooltip' @@ -12,18 +16,21 @@ export function getSortedVotes(votesMap: Record) { .sort((a, b) => a.timestamp - b.timestamp) } -export function getSegregatedVotes(votes: VoteWithAddress[]) { - const yesVotes: VoteWithAddress[] = [] - const noVotes: VoteWithAddress[] = [] - const abstainVotes: VoteWithAddress[] = [] +export function getSegregatedVotes(votes: VoteWithAddress[], profileMap: Map) { + const yesVotes: VoteWithProfile[] = [] + const noVotes: VoteWithProfile[] = [] + const abstainVotes: VoteWithProfile[] = [] for (const vote of votes) { - if (vote.choice === 1) { - yesVotes.push(vote) - } else if (vote.choice === 2) { - noVotes.push(vote) - } else if (vote.choice === 3) { - abstainVotes.push(vote) + const profile = profileMap.get(vote.address.toLowerCase()) + const voteWithProfile = { ...vote, profile } + + if (voteWithProfile.choice === 1) { + yesVotes.push(voteWithProfile) + } else if (voteWithProfile.choice === 2) { + noVotes.push(voteWithProfile) + } else if (voteWithProfile.choice === 3) { + abstainVotes.push(voteWithProfile) } } @@ -70,13 +77,24 @@ function getOrCreateTooltip(chart: Chart<'line'>) { return customTooltip } -export function externalTooltipHandler(context: ScriptableTooltipContext<'line'>) { +type TooltipHandlerProps = { + context: ScriptableTooltipContext<'line'> + datasetMap: Record + title: (choice: string, vp: number) => string +} +export function externalTooltipHandler({ context, datasetMap, title }: TooltipHandlerProps) { // Tooltip Element const { chart, tooltip } = context const tooltipEl = getOrCreateTooltip(chart) const dataPoint = tooltip.dataPoints?.[0].raw as { x: number; y: number } | undefined const dataIdx: number | undefined = tooltip.dataPoints?.[0].dataIndex - const datasetLabel = tooltip.dataPoints?.[0].dataset.label + const datasetLabel = tooltip.dataPoints?.[0].dataset.label || '' + + const vote = datasetMap[datasetLabel]?.[dataIdx] + + const username = vote?.profile?.name || vote?.address.slice(0, 7) + const userVP = vote?.vp || 0 + const formattedTimestamp = Time(dataPoint?.x || 0).format('DD/MM/YY, HH:mm z') // Hide if no tooltip if (tooltip.opacity === 0) { @@ -84,17 +102,26 @@ export function externalTooltipHandler(context: ScriptableTooltipContext<'line'> return } + //Set Avatar + const avatar = document.createElement('img') + avatar.className = 'avatar' + avatar.src = vote?.profile?.avatar?.snapshots?.face256 || DEFAULT_AVATAR_IMAGE + // Set Text const textContainer = document.createElement('div') textContainer.className = 'container' const titleElement = document.createElement('div') - const title = document.createTextNode(`voted "${datasetLabel}"`) - titleElement.appendChild(title) + const userElement = document.createElement('strong') + const userText = document.createTextNode(username) + userElement.appendChild(userText) + titleElement.appendChild(userElement) + const titleText = document.createTextNode(title(datasetLabel, userVP)) + titleElement.appendChild(titleText) titleElement.className = 'title' const detailsElement = document.createElement('div') - const details = document.createTextNode(`Acc. ${dataPoint?.y.toLocaleString('en-US')} VP`) + const details = document.createTextNode(`${formattedTimestamp}. Acc. VP: ${dataPoint?.y.toLocaleString('en-US')}`) detailsElement.appendChild(details) detailsElement.className = 'details' @@ -107,11 +134,12 @@ export function externalTooltipHandler(context: ScriptableTooltipContext<'line'> } // Add new children + tooltipEl.appendChild(avatar) tooltipEl.appendChild(textContainer) const { offsetLeft: positionX, offsetTop: positionY, width } = chart.canvas - const maxX = width * 0.9 + const maxX = width * 0.8 // Display, position, and set styles for font tooltipEl.style.opacity = '1' diff --git a/src/intl/en.json b/src/intl/en.json index 98b6760c8..3a0a14708 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -870,8 +870,7 @@ "votes_chart": { "title": "Voting Timeline", "pass_threshold": "Acceptance threshold", - "tooltip_label": "{choice}: {vp} VP", - "tooltip_voter": "\nVoter: {address}\nVP used: {vp}" + "tooltip_title": ", voted \"{choice}\" with {vp} VP" } }, "proposal_bidding_tendering": { diff --git a/src/utils/Catalyst/index.ts b/src/utils/Catalyst/index.ts index a3916f22a..6a2268407 100644 --- a/src/utils/Catalyst/index.ts +++ b/src/utils/Catalyst/index.ts @@ -6,7 +6,7 @@ import { isSameAddress } from '../../entities/Snapshot/utils' import { Avatar, ProfileResponse } from './types' const CATALYST_URL = 'https://peer.decentraland.org' -const DEFAULT_AVATAR_IMAGE = 'https://decentraland.org/images/male.png' +export const DEFAULT_AVATAR_IMAGE = 'https://decentraland.org/images/male.png' export async function getProfile(address: string): Promise { if (!isEthereumAddress(address)) {