Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

explorer: rework proposal page #1446

Merged
merged 3 commits into from
Jul 17, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion explorer/explorerroutes.go
Original file line number Diff line number Diff line change
Expand Up @@ -1812,16 +1812,19 @@ func (exp *explorerUI) ProposalPage(w http.ResponseWriter, r *http.Request) {
}
}

commonData := exp.commonData(r)
str, err := exp.templates.execTemplateToString("proposal", struct {
*CommonPageData
Data *pitypes.ProposalInfo
PoliteiaURL string
TimeRemaining string
Metadata *pitypes.ProposalMetadata
}{
CommonPageData: exp.commonData(r),
CommonPageData: commonData,
Data: proposalInfo,
PoliteiaURL: exp.politeiaAPIURL,
TimeRemaining: timeLeft,
Metadata: proposalInfo.Metatdata(int64(commonData.Tip.Height), int64(exp.ChainParams.TargetTimePerBlock/time.Second)),
})

if err != nil {
Expand Down
71 changes: 70 additions & 1 deletion gov/politeia/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@

package types

import piapi "github.com/decred/politeia/politeiawww/api/www/v1"
import (
"strconv"

piapi "github.com/decred/politeia/politeiawww/api/www/v1"
)

// Politeia votes occur in 2016 block windows.
const windowSize = 2016

// ProposalInfo holds the proposal details as document here
// https://github.com/decred/politeia/blob/master/politeiawww/api/www/v1/api.md#user-proposals.
Expand Down Expand Up @@ -204,3 +211,65 @@ func (a *ProposalInfo) IsEqual(b *ProposalInfo) bool {
}
return true
}

// ProposalMetadata contains some status-dependent data representations for
// display purposes.
type ProposalMetadata struct {
// The start height of the vote. The end height is already part of the
// ProposalInfo struct.
StartHeight int64
// Time until start for "Authorized" proposals, Time until done for "Started"
// proposals.
SecondsTil int64
IsPassing bool
Approval float32
Rejection float32
Yes int64
No int64
VoteCount int64
QuorumCount int64
QuorumAchieved bool
PassPercent float32
}

// Metadata performs some common manipulations of the ProposalInfo data to
// prepare figures for display. Many of these manipulations require a tip height
// and a target block time for the network, so those must be provided as
// arguments.
func (pinfo *ProposalInfo) Metatdata(tip, targetBlockTime int64) *ProposalMetadata {
meta := new(ProposalMetadata)
desc := pinfo.VoteStatus.ShortDesc()
switch desc {
case "Authorized":
blocksTil := windowSize - tip%windowSize
meta.StartHeight = tip + blocksTil
meta.SecondsTil = blocksTil * targetBlockTime
case "Started", "Finished":
endHeight, _ := strconv.ParseInt(pinfo.ProposalVotes.Endheight, 10, 64)
meta.StartHeight = endHeight - windowSize
for _, count := range pinfo.VoteResults {
switch count.Option.OptionID {
case "yes":
meta.Yes = count.VotesReceived
case "no":
meta.No = count.VotesReceived
}
}
meta.VoteCount = meta.Yes + meta.No
quorumPct := float32(pinfo.QuorumPercentage) / 100
meta.QuorumCount = int64(quorumPct * float32(pinfo.NumOfEligibleVotes))
meta.PassPercent = float32(pinfo.PassPercentage) / 100
pctVoted := float32(meta.VoteCount) / float32(pinfo.NumOfEligibleVotes)
meta.QuorumAchieved = pctVoted > quorumPct
if meta.VoteCount > 0 {
meta.Approval = float32(meta.Yes) / float32(meta.VoteCount)
meta.Rejection = 1 - meta.Approval
}
meta.IsPassing = meta.Approval > meta.PassPercent
if desc == "Started" {
blocksLeft := windowSize - tip%windowSize
meta.SecondsTil = blocksLeft * targetBlockTime
}
}
return meta
}
2 changes: 2 additions & 0 deletions public/fonts/icomoon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/fonts/icomoon.ttf
Binary file not shown.
Binary file modified public/fonts/icomoon.woff
Binary file not shown.
50 changes: 38 additions & 12 deletions public/js/controllers/proposal_controller.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Controller } from 'stimulus'
import { MiniMeter } from '../helpers/meters.js'
import { darkEnabled } from '../services/theme_service'
import globalEventBus from '../services/event_bus_service'
import { getDefault } from '../helpers/module_helper'
import { multiColumnBarPlotter, synchronize } from '../helpers/chart_helper'
import dompurify from 'dompurify'
Expand All @@ -20,7 +23,6 @@ let percentConfig = {
ylabel: 'Approval (%)',
colors: ['#2971FF'],
labelsSeparateLines: true,
labelsDiv: 'percent-of-votes-legend',
underlayCallback: function (context, area, g) {
let xVals = g.xAxisExtremes()
let xl = g.toDomCoords(xVals[0], 60)
Expand All @@ -42,7 +44,6 @@ let cumulativeConfig = {
labels: ['Date', 'Total Votes'],
ylabel: 'Vote Count',
colors: ['#2971FF'],
labelsDiv: 'cumulative-legend',
underlayCallback: function (context, area, g) {
let xVals = g.xAxisExtremes()
let xl = g.toDomCoords(xVals[0], 8269)
Expand All @@ -64,10 +65,10 @@ function legendFormatter (data) {
if (data.x == null) {
html = data.series.map(function (series) {
return series.dashHTML + ' <span style="color:' +
series.color + ';">' + series.labelHTML + ' </span>'
}).join('<br>')
series.color + ';">' + series.labelHTML + ' </span> '
}).join('')
} else {
html = this.getLabels()[0] + ': ' + data.xHTML + ' UTC <br>'
html = this.getLabels()[0] + ': ' + data.xHTML + ' UTC &nbsp;&nbsp;'
data.series.forEach(function (series, index) {
if (!series.isVisible) return

Expand All @@ -78,12 +79,12 @@ function legendFormatter (data) {
} else if (index === 0) {
html = ''
} else {
html += ' <br> '
// html += ' <br> '
}

var labeledData = '<span style="color:' + series.color + ';">' +
series.labelHTML + ' </span> : ' + Math.abs(series.y)
html += series.dashHTML + ' ' + labeledData + '' + symbol
html += series.dashHTML + ' ' + labeledData + '' + symbol + ' &nbsp;'
})
}
dompurify.sanitize(html)
Expand All @@ -98,7 +99,6 @@ var hourlyVotesConfig = {
ylabel: 'Votes Per Hour',
xlabel: 'Date',
colors: ['#2DD8A3', '#ED6D47'],
labelsDiv: 'votes-by-time-legend',
fillColors: ['rgb(150,235,209)', 'rgb(246,182,163)']
}

Expand All @@ -111,10 +111,24 @@ let cumulativeData
let hourlyVotesData
export default class extends Controller {
static get targets () {
return ['token']
return ['token', 'approvalMeter', 'cumulative', 'cumulativeLegend',
'approval', 'approvalLegend', 'log', 'logLegend'
]
}

async connect () {
if (this.hasApprovalMeterTarget) {
let d = this.approvalMeterTarget.dataset
var opts = {
darkMode: darkEnabled(),
segments: [
{ end: d.threshold, color: '#ed6d47' },
{ end: 1, color: '#2dd8a3' }
]
}
this.approvalMeter = new MiniMeter(this.approvalMeterTarget, opts)
}

let response = await axios.get('/api/proposal/' + this.tokenTarget.dataset.hash)
chartData = response.data

Expand All @@ -124,10 +138,13 @@ export default class extends Controller {

this.setChartsData()
this.plotGraph()
this.setNightMode = this._setNightMode.bind(this)
globalEventBus.on('NIGHT_MODE', this.setNightMode)
}

disconnect () {
gs.map((chart) => { chart.destroy() })
globalEventBus.off('NIGHT_MODE', this.setNightMode)
}

setChartsData () {
Expand All @@ -152,19 +169,22 @@ export default class extends Controller {
}

plotGraph () {
percentConfig.labelsDiv = this.approvalLegendTarget
cumulativeConfig.labelsDiv = this.cumulativeLegendTarget
hourlyVotesConfig.labelsDiv = this.logLegendTarget
gs = [
new Dygraph(
document.getElementById('percent-of-votes'),
this.approvalTarget,
percentData,
percentConfig
),
new Dygraph(
document.getElementById('cumulative'),
this.cumulativeTarget,
cumulativeData,
cumulativeConfig
),
new Dygraph(
document.getElementById('votes-by-time'),
this.logTarget,
hourlyVotesData,
hourlyVotesConfig
)
Expand All @@ -177,4 +197,10 @@ export default class extends Controller {

synchronize(gs, options)
}

_setNightMode (state) {
if (this.approvalMeter) {
this.approvalMeter.setDarkMode(state.nightMode)
}
}
}
90 changes: 69 additions & 21 deletions public/js/helpers/meters.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function sleep (ms) {
// parameter should be a block element with class .meter. CSS classes .large-gap
// and .arch can also be applied for increasingly large gap angle, with .arch
// being a semi-circle. Any contents of parent will be replaced with the Meter.
// Apply class .lil for a smaller meter.
class Meter {
constructor (parent, opts) {
opts = opts || {}
Expand Down Expand Up @@ -161,6 +162,21 @@ class Meter {
this.ctx.fillText(text, pt.x, pt.y, maxWidth)
}

drawIndicator (value, color) {
var ctx = this.ctx
var opts = this.options
var theme = this.activeTheme
var halfLen = this.norm(opts.meterWidth) * 0.5
var start = super.normedPolarToCartesian(this.radius - halfLen, value)
var end = super.normedPolarToCartesian(this.radius + halfLen, value)
ctx.lineWidth = 1.5
ctx.strokeStyle = color
super.dot(start, color, opts.dotSize)
super.dot(end, color, opts.dotSize)
ctx.strokeStyle = theme.text
super.line(start, end)
}

async animate (key, target) {
// key is a string referencing any property of Meter.data.
var opts = this.options
Expand Down Expand Up @@ -209,7 +225,7 @@ export class VoteMeter extends Meter {
opts.revoteColor = opts.revoteColor || '#ffe4a7'
opts.rejectColor = opts.rejectColor || '#ed6d47'
opts.dotColor = opts.dotColor || '#888'
opts.showIndicator = opts.showIndicator || true
if (opts.showIndicator === undefined) opts.showIndictor = true
this.darkTheme = opts.darkTheme || {
text: 'white',
tray: '#999'
Expand Down Expand Up @@ -306,7 +322,8 @@ export class ProgressMeter extends Meter {
opts.meterWidth = opts.meterWidth || 14
opts.centralFontSize = opts.centralFontSize || 18
opts.successColor = opts.successColor = '#2dd8a3'
opts.showIndicator = opts.showIndicator || true
opts.dotSize = opts.dotSize || 3
if (opts.showIndicator === undefined) opts.showIndictor = true
this.darkTheme = opts.darkTheme || {
tray: '#777',
text: 'white'
Expand All @@ -323,37 +340,22 @@ export class ProgressMeter extends Meter {
this.animate('progress', progress)
}

drawIndicator (value, color) {
var ctx = this.ctx
var opts = this.options
var theme = this.activeTheme
var halfLen = this.norm(opts.meterWidth) * 0.5
var start = super.normedPolarToCartesian(this.radius - halfLen, value)
var end = super.normedPolarToCartesian(this.radius + halfLen, value)
ctx.lineWidth = 1.5
ctx.strokeStyle = color
super.dot(start, color, 3)
super.dot(end, color, 3)
ctx.strokeStyle = theme.text
super.line(start, end)
}

draw () {
super.clear()
var ctx = this.ctx
var opts = this.options
var theme = this.activeTheme

ctx.lineWidth = opts.meterWidth
ctx.lineWidth = opts.meterWidth * 0.95 // Prevents rough looking edge
var c = this.data.progress >= this.threshold ? opts.successColor : theme.tray
super.segment(0, 1, c)

this.drawIndicator(this.threshold, c)
super.drawIndicator(this.threshold, c)

ctx.lineWidth = opts.meterWidth
super.segment(0, this.data.progress, opts.meterColor)

this.drawIndicator(this.data.progress, theme.text)
super.drawIndicator(this.data.progress, theme.text)

if (opts.showIndicator && this.data.progress >= this.threshold) {
ctx.fillStyle = opts.successColor
Expand All @@ -362,7 +364,53 @@ export class ProgressMeter extends Meter {

var offset = opts.showIndicator ? super.denorm(0.05) : 0
this.ctx.fillStyle = this.activeTheme.text
this.ctx.font = `bold ${opts.centralFontSize}px sans-serif`
this.ctx.font = `500 ${opts.centralFontSize}px 'source-sans-pro-semibold', sans-serif`
this.write(`${parseInt(this.data.progress * 100)}%`, makePt(this.middle.x, this.middle.y + offset), super.denorm(0.5))
}
}

// Mini meter is a semi-circular meter with a needle. The segment definitions
// must be passed as an array of objects with the structure
// [{end: float, color: string}, {end: float, color: string}, ...], where end is
// the end of the segments range on the scale [0, 1]. The first range is assumed
// to start at 0, and each subsequent segment will start at the previous
// segment's end. The MiniMeter is designed to work with the .arch.lil CSS
// classes, but not limited to that particular class.
export class MiniMeter extends Meter {
constructor (parent, opts) {
super(parent, opts)
this.buttCap()
opts = this.options
this.radius = opts.radius || 0.475
this.darkTheme = opts.darkTheme || { text: 'white' }
this.lightTheme = opts.lightTheme || { text: '#333333' }
this.activeTheme = opts.darkMode ? this.darkTheme : this.lightTheme
opts.meterWidth = opts.meterWidth || 18
this.value = parseFloat(parent.dataset.value)
this.draw()
}

draw () {
super.clear()
var ctx = this.ctx
var opts = this.options
ctx.lineWidth = opts.meterWidth
var textColor = this.activeTheme.text

// Draw the segments.
var start = 0
opts.segments.forEach(segment => {
super.segment(start, segment.end, segment.color)
start = segment.end
})

// Draw the needle
var tipLen = this.norm(opts.meterWidth) * 0.75
var center = super.normedPolarToCartesian(0, 0)
var end = super.normedPolarToCartesian(this.radius + tipLen, this.value)
super.dot(center, textColor, 7)
ctx.strokeStyle = textColor
ctx.lineWidth = 5
super.line(center, end)
}
}
Loading