diff --git a/html/index.js b/html/index.js index 2c1e722..5b51ada 100644 --- a/html/index.js +++ b/html/index.js @@ -202,7 +202,8 @@ async function clickCount() { // Step election let worker = new Worker('worker.js'); let election; - let trComment, trExhausted1, trExhausted2, trLTF1, trLTF2, trTotal, trQuota, trVRE; + let trStageNo, trStageKind, trComment; + let trExhausted1, trExhausted2, trLTF1, trLTF2, trTotal, trQuota, trVRE; let olLogs; worker.onmessage = function(evt) { @@ -220,9 +221,22 @@ async function clickCount() { if (evt.data.type === 'init') { election = evt.data.election; - // Comment row - trComment = document.createElement('tr'); + // Comment rows + trStageNo = document.createElement('tr'); + trStageNo.classList.add('stage-no'); let elTd = document.createElement('td'); + trStageNo.appendChild(elTd); + tblResults.appendChild(trStageNo); + + trStageKind = document.createElement('tr'); + trStageKind.classList.add('stage-kind'); + elTd = document.createElement('td'); + trStageKind.appendChild(elTd); + tblResults.appendChild(trStageKind); + + trComment = document.createElement('tr'); + trComment.classList.add('stage-comment'); + elTd = document.createElement('td'); trComment.appendChild(elTd); tblResults.appendChild(trComment); @@ -233,7 +247,7 @@ async function clickCount() { elTd = document.createElement('td'); elTd.setAttribute('rowspan', '2'); - elTd.style.borderTop = '1px solid black'; + elTd.classList.add('bt'); elTd.innerText = candidate; elTr1.appendChild(elTd); @@ -251,7 +265,7 @@ async function clickCount() { elTd = document.createElement('td'); elTd.setAttribute('rowspan', '2'); - elTd.style.borderTop = '1px solid black'; + elTd.classList.add('bt'); elTd.innerText = 'Exhausted'; trExhausted1.appendChild(elTd); @@ -268,7 +282,7 @@ async function clickCount() { elTd = document.createElement('td'); elTd.setAttribute('rowspan', '2'); - elTd.style.borderTop = '1px solid black'; + elTd.classList.add('bt'); elTd.innerText = 'Loss to fraction'; trLTF1.appendChild(elTd); @@ -279,7 +293,7 @@ async function clickCount() { trTotal = document.createElement('tr'); trTotal.classList.add('info'); elTd = document.createElement('td'); - elTd.style.borderTop = '1px solid black'; + elTd.classList.add('bt'); elTd.innerText = 'Total'; trTotal.appendChild(elTd); tblResults.appendChild(trTotal); @@ -288,8 +302,8 @@ async function clickCount() { trQuota = document.createElement('tr'); trQuota.classList.add('info'); elTd = document.createElement('td'); - elTd.style.borderTop = '1px solid black'; - elTd.style.borderBottom = '1px solid black'; + elTd.classList.add('bt'); + elTd.classList.add('bb'); elTd.innerText = 'Quota'; trQuota.appendChild(elTd); tblResults.appendChild(trQuota); @@ -299,8 +313,8 @@ async function clickCount() { trVRE = document.createElement('tr'); trVRE.classList.add('info'); elTd = document.createElement('td'); - elTd.style.borderTop = '1px solid black'; - elTd.style.borderBottom = '1px solid black'; + elTd.classList.add('bt'); + elTd.classList.add('bb'); elTd.innerText = 'Vote required for election'; trVRE.appendChild(elTd); tblResults.appendChild(trVRE); @@ -336,7 +350,15 @@ async function clickCount() { // Display results elTd = document.createElement('td'); - elTd.innerText = result.stage + '. ' + result.comment; + elTd.innerText = result.stage; + trStageNo.appendChild(elTd); + + elTd = document.createElement('td'); + elTd.innerText = result.stage_kind; + trStageKind.appendChild(elTd); + + elTd = document.createElement('td'); + elTd.innerText = result.comment; trComment.appendChild(elTd); for (let [candidate, countCard] of result.candidates) { @@ -349,7 +371,7 @@ async function clickCount() { } else if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED || countCard.state === py.pyRCV2.model.CandidateState.DISTRIBUTING_SURPLUS) { elTd.classList.add('elected'); } - elTd.style.borderTop = '1px solid black'; + elTd.classList.add('bt'); elTd.innerHTML = ppVotes(countCard.transfers); elTr1.appendChild(elTd); @@ -383,7 +405,7 @@ async function clickCount() { // Display exhausted votes elTd = document.createElement('td'); elTd.classList.add('count'); - elTd.style.borderTop = '1px solid black'; + elTd.classList.add('bt'); elTd.innerHTML = ppVotes(result.exhausted.transfers); trExhausted1.appendChild(elTd); @@ -395,7 +417,7 @@ async function clickCount() { // Display loss to fraction elTd = document.createElement('td'); elTd.classList.add('count'); - elTd.style.borderTop = '1px solid black'; + elTd.classList.add('bt'); elTd.innerHTML = ppVotes(result.loss_fraction.transfers); trLTF1.appendChild(elTd); @@ -413,15 +435,15 @@ async function clickCount() { // Display total elTd = document.createElement('td'); elTd.classList.add('count'); - elTd.style.borderTop = '1px solid black'; + elTd.classList.add('bt'); elTd.innerHTML = ppVotes(result.total); trTotal.appendChild(elTd); // Display quota elTd = document.createElement('td'); elTd.classList.add('count'); - elTd.style.borderTop = '1px solid black'; - elTd.style.borderBottom = '1px solid black'; + elTd.classList.add('bt'); + elTd.classList.add('bb'); elTd.innerHTML = ppVotes(result.quota); trQuota.appendChild(elTd); @@ -429,8 +451,8 @@ async function clickCount() { if (result.vote_required_election !== null) { elTd = document.createElement('td'); elTd.classList.add('count'); - elTd.style.borderTop = '1px solid black'; - elTd.style.borderBottom = '1px solid black'; + elTd.classList.add('bt'); + elTd.classList.add('bb'); elTd.innerHTML = ppVotes(result.vote_required_election); trVRE.appendChild(elTd); } @@ -462,11 +484,11 @@ async function clickCount() { elTd.innerHTML = 'ELECTED ' + countCard.order_elected; winners.append([candidate, countCard]); } - elTd.style.borderTop = '1px solid black'; + elTd.classList.add('bt'); elTr1.appendChild(elTd); } - elTd.style.borderBottom = '1px solid black'; + elTd.classList.add('bb'); let elP = document.createElement('p'); elP.innerText = 'Count complete. The winning candidates are, in order of election:' @@ -541,6 +563,7 @@ async function clickCount() { if (result.indexOf('.') >= 0) { result = result.substring(0, result.indexOf('.')) + '' + result.substring(result.indexOf('.')) + ''; } + result = result.replace('-', '−'); return result; } } diff --git a/html/main.css b/html/main.css index 033878d..be1024f 100644 --- a/html/main.css +++ b/html/main.css @@ -16,14 +16,25 @@ along with this program. If not, see . */ +@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro&display=swap'); + html, body { - font-family: 'Liberation Sans', FreeSans, Helvetica, Arial, sans-serif; + font-family: 'Source Sans Pro', sans-serif; } body { padding: 0.5em; } +a { + color: #1d46c4; + text-decoration: none; +} +a:hover { + color: #1d3da2; + text-decoration: underline; +} + /* Menu styling */ .menudiv { @@ -69,24 +80,41 @@ td.count sup { font-size: 0.6rem; top: 0; } -.result tr:first-child td { - vertical-align: bottom; +tr.stage-no td, tr.stage-kind td, tr.stage-comment td { + text-align: center; +} +tr.stage-no td:not(:first-child) { + border-top: 1px solid #76858c; +} +tr.stage-kind td:not(:first-child) { + font-size: 0.75em; + min-width: 5rem; + color: #1b2839; + background-color: #f0f5fb; + color-adjust: exact; + -webkit-print-color-adjust: exact; } td.excluded { - background-color: #fecfcfff; + background-color: #fde2e2; color-adjust: exact; -webkit-print-color-adjust: exact; } td.elected { - background-color: #d1fca7ff; + background-color: #e0fdc5; color-adjust: exact; -webkit-print-color-adjust: exact; } tr.info td { - background-color: #edededff; + background-color: #f0f5fb; color-adjust: exact; -webkit-print-color-adjust: exact; } +td.bt { + border-top: 1px solid #76858c; +} +td.bb { + border-bottom: 1px solid #76858c; +} /* BLT input tool */ diff --git a/html/worker.js b/html/worker.js index 914ebf3..79fd78d 100644 --- a/html/worker.js +++ b/html/worker.js @@ -145,6 +145,7 @@ function handleException(ex) { function resultToJS(result) { return { 'stage': stage, + 'stage_kind': result.stage_kind, 'comment': result.comment, 'logs': result.logs, 'candidates': result.candidates.py_items().map(([c, cc]) => [c.py_name, { diff --git a/pyRCV2/cli/stv.py b/pyRCV2/cli/stv.py index dee8f5b..79a7650 100644 --- a/pyRCV2/cli/stv.py +++ b/pyRCV2/cli/stv.py @@ -61,7 +61,10 @@ def add_parser(subparsers): parser.add_argument('--pp-decimals', type=int, default=2, help='print votes to specified decimal places in results report (default: 2)') def print_step(args, stage, result): - print('{}. {}'.format(stage, result.comment)) + if result.stage_kind: + print('{}. {} {}'.format(stage, result.stage_kind, result.comment)) + else: + print('{}. {}'.format(stage, result.comment)) if result.logs: print(' '.join(result.logs)) diff --git a/pyRCV2/method/base_stv.py b/pyRCV2/method/base_stv.py index f29eec5..2d3dd56 100644 --- a/pyRCV2/method/base_stv.py +++ b/pyRCV2/method/base_stv.py @@ -99,7 +99,7 @@ class BaseSTVCounter: self.compute_quota() self.elect_meeting_quota() - return self.make_result('First preferences') + return self.make_result(None, 'First preferences') def distribute_first_preferences(self): """ @@ -180,6 +180,7 @@ class BaseSTVCounter: if self.num_elected >= self.election.seats: __pragma__('opov') return CountCompleted( + None, 'Count complete', self.logs, self.candidates, @@ -193,7 +194,6 @@ class BaseSTVCounter: # Are there just enough candidates to fill all the seats? if self.options['bulk_elect']: - # Include EXCLUDING to avoid interrupting an exclusion if len(self.election.candidates) - self.num_withdrawn - self.num_excluded + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.EXCLUDING) <= self.election.seats: # Declare elected all remaining candidates candidates_elected = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED] @@ -212,7 +212,7 @@ class BaseSTVCounter: constraints.stabilise_matrix(self) self.logs.extend(constraints.guard_or_doom(self)) - return self.make_result('Bulk election') + return self.make_result(None, 'Bulk election') def can_defer_surpluses(self, has_surplus): """ @@ -301,7 +301,7 @@ class BaseSTVCounter: self.elect_meeting_quota() - return self.make_result('Surplus of ' + candidate_surplus.name) + return self.make_result('Surplus of', candidate_surplus.name) def do_surplus(self, candidate_surplus, count_card, surplus): """ @@ -317,7 +317,8 @@ class BaseSTVCounter: # If we did not perform bulk election in before_surpluses: Are there just enough candidates to fill all the seats? if not self.options['bulk_elect']: - if len(self.election.candidates) - self.num_withdrawn - self.num_excluded <= self.election.seats: + # Include EXCLUDING to avoid interrupting an exclusion + if len(self.election.candidates) - self.num_withdrawn - self.num_excluded + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.EXCLUDING) <= self.election.seats: # Declare elected one remaining candidate at a time hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL or cc.state == CandidateState.GUARDED] hopefuls.sort(key=lambda x: x[1].votes, reverse=True) @@ -344,7 +345,7 @@ class BaseSTVCounter: else: self.logs.append(self.pretty_join(order_elected) + ' are elected to fill the remaining vacancies.') - return self.make_result('Bulk election') + return self.make_result(None, 'Bulk election') def exclude_doomed(self): """ @@ -369,13 +370,21 @@ class BaseSTVCounter: Exclude the lowest ranked hopeful(s) """ - candidates_excluded = self.candidates_to_exclude() + # Continue current exclusion if applicable + if self._exclusion is not None: + self.logs.append('Continuing exclusion of ' + self.pretty_join([c.name for c, cc in self._exclusion[0]]) + '.') + __pragma__('opov') + candidates_excluded = self._exclusion[0] + __pragma__('noopov') + else: + candidates_excluded = self.candidates_to_exclude() + if len(candidates_excluded) > 0: + if len(candidates_excluded) == 1: + self.logs.append('No surpluses to distribute, so ' + candidates_excluded[0][0].name + ' is excluded.') + else: + self.logs.append('No surpluses to distribute, so ' + self.pretty_join([c.name for c, cc in candidates_excluded]) + ' are excluded.') + if len(candidates_excluded) > 0: - if len(candidates_excluded) == 1: - self.logs.append('No surpluses to distribute, so ' + candidates_excluded[0][0].name + ' is excluded.') - else: - self.logs.append('No surpluses to distribute, so ' + self.pretty_join([c.name for c, cc in candidates_excluded]) + ' are excluded.') - return self.exclude_candidates(candidates_excluded) def exclude_candidates(self, candidates_excluded): @@ -434,7 +443,7 @@ class BaseSTVCounter: self.elect_meeting_quota() - return self.make_result('Exclusion of ' + ', '.join([c.name for c, cc in candidates_excluded])) + return self.make_result('Exclusion of', ', '.join([c.name for c, cc in candidates_excluded])) def candidates_to_bulk_exclude(self, hopefuls): """ @@ -479,13 +488,6 @@ class BaseSTVCounter: Returns List[Tuple[Candidate, CountCard]] """ - # Continue current exclusion if applicable - if self._exclusion is not None: - self.logs.append('Continuing exclusion of ' + self.pretty_join([c.name for c, cc in self._exclusion[0]]) + '.') - __pragma__('opov') - return self._exclusion[0] - __pragma__('noopov') - hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL] hopefuls.sort(key=lambda x: x[1].votes) @@ -775,9 +777,10 @@ class BaseSTVCounter: return num return num.round(self.options['round_tvs'], num.ROUND_DOWN) - def make_result(self, comment): + def make_result(self, stage_kind, comment): __pragma__('opov') result = CountStepResult( + stage_kind, comment, self.logs, self.candidates, diff --git a/pyRCV2/method/meek.py b/pyRCV2/method/meek.py index e9141b7..2bc208e 100644 --- a/pyRCV2/method/meek.py +++ b/pyRCV2/method/meek.py @@ -141,7 +141,7 @@ class MeekSTVCounter(BaseSTVCounter): self.logs.append(self.total.pp(2) + ' usable votes, so the quota is ' + self.quota.pp(2) + '.') self.elect_meeting_quota() - return self.make_result('First preferences') + return self.make_result(None, 'First preferences') def distribute_recursively(self, tree, remaining_multiplier): if tree.next_exhausted is None: @@ -266,7 +266,7 @@ class MeekSTVCounter(BaseSTVCounter): # NB: We could do this earlier, but this shows the flow of the election more clearly in the count sheet self.elect_meeting_quota() - return self.make_result('Surpluses distributed') + return self.make_result(None, 'Surpluses distributed') def do_exclusion(self, candidates_excluded): """ diff --git a/pyRCV2/model.py b/pyRCV2/model.py index 50b9e1f..ab4be7b 100644 --- a/pyRCV2/model.py +++ b/pyRCV2/model.py @@ -155,7 +155,8 @@ class CountCard: __pragma__('noopov') class CountStepResult: - def __init__(self, comment, logs, candidates, exhausted, loss_fraction, total, quota, vote_required_election): + def __init__(self, stage_kind, comment, logs, candidates, exhausted, loss_fraction, total, quota, vote_required_election): + self.stage_kind = stage_kind self.comment = comment self.logs = logs @@ -176,7 +177,7 @@ class CountStepResult: candidates[c] = cc.clone() __pragma__('noopov') - return CountStepResult(self.comment, candidates, self.exhausted.clone(), self.loss_fraction.clone(), self.total, self.quota) + return CountStepResult(self.stage_kind, self.comment, candidates, self.exhausted.clone(), self.loss_fraction.clone(), self.total, self.quota) class CountCompleted(CountStepResult): pass diff --git a/tests/test_aec.py b/tests/test_aec.py index 029ff45..878c96a 100644 --- a/tests/test_aec.py +++ b/tests/test_aec.py @@ -60,7 +60,8 @@ def test_aec_tas19(): result = counter.step() comment = data[1][i] - assert result.comment == comment, 'Failed to verify comment' + result_comment = (result.stage_kind + ' ' + result.comment) if result.stage_kind else result.comment + assert result_comment == comment, 'Failed to verify comment' for j, cand in enumerate(candidates): votes = pyRCV2.numbers.Num(data[j + 2][i]) diff --git a/tests/test_ers97.py b/tests/test_ers97.py index c346018..27b908d 100644 --- a/tests/test_ers97.py +++ b/tests/test_ers97.py @@ -67,7 +67,8 @@ def test_ers97_py(): result = counter.step() comment = data[1][i] - assert result.comment == comment, 'Failed to verify comment' + result_comment = (result.stage_kind + ' ' + result.comment) if result.stage_kind else result.comment + assert result_comment == comment, 'Failed to verify comment' for j, cand in enumerate(candidates): votes = pyRCV2.numbers.Num(data[j + 2][i]) @@ -112,7 +113,8 @@ def test_ers97_js(): assert ctx.eval('result = counter.step();') comment = data[1][i] - assert ctx.eval('result.comment') == comment, 'Failed to verify comment' + result_comment = ctx.eval('result.stage_kind ? (result.stage_kind + " " + result.comment) : result.comment') + assert result_comment == comment, 'Failed to verify comment' for j, cand in enumerate(candidates): ctx.eval('votes = py.pyRCV2.numbers.Num("{}");'.format(data[j + 2][i])) diff --git a/tests/test_prsa.py b/tests/test_prsa.py index 1552760..cd560b8 100644 --- a/tests/test_prsa.py +++ b/tests/test_prsa.py @@ -69,7 +69,8 @@ def test_prsa1(): # Stage 2 result = counter.step() - assert result.comment == 'Surplus of Grey' + assert result.stage_kind == 'Surplus of' + assert result.comment == 'Grey' assert isclose(result.candidates[c_evans].votes, 2234) assert isclose(result.candidates[c_grey].votes, 13001) assert isclose(result.candidates[c_thomson].votes, 7468) @@ -81,7 +82,8 @@ def test_prsa1(): # Stage 3 result = counter.step() - assert result.comment == 'Surplus of Ames' + assert result.stage_kind == 'Surplus of' + assert result.comment == 'Ames' assert isclose(result.candidates[c_evans].votes, 3038) assert isclose(result.candidates[c_grey].votes, 13001) assert isclose(result.candidates[c_thomson].votes, 8674) @@ -93,7 +95,8 @@ def test_prsa1(): # Stage 4 result = counter.step() - assert result.comment == 'Surplus of Spears' + assert result.stage_kind == 'Surplus of' + assert result.comment == 'Spears' assert isclose(result.candidates[c_evans].votes, 4823) assert isclose(result.candidates[c_grey].votes, 13001) assert isclose(result.candidates[c_thomson].votes, 8674) @@ -105,25 +108,29 @@ def test_prsa1(): # Stage 5 result = counter.step() - assert result.comment == 'Exclusion of Reid' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Reid' assert isclose(result.candidates[c_reid].transfers, -1000) assert isclose(result.candidates[c_white].transfers, 1000) # Stage 6 result = counter.step() - assert result.comment == 'Exclusion of Reid' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Reid' assert isclose(result.candidates[c_reid].transfers, -617) assert isclose(result.candidates[c_white].transfers, 617) # Stage 7 result = counter.step() - assert result.comment == 'Exclusion of Reid' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Reid' assert isclose(result.candidates[c_reid].transfers, -402) assert isclose(result.candidates[c_evans].transfers, 402) # Stage 8 result = counter.step() - assert result.comment == 'Exclusion of Reid' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Reid' assert isclose(result.candidates[c_reid].transfers, -1785) assert isclose(result.candidates[c_evans].transfers, 595) assert isclose(result.candidates[c_thomson].transfers, 1190) @@ -139,39 +146,45 @@ def test_prsa1(): # Stage 9 result = counter.step() - assert result.comment == 'Exclusion of Evans' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Evans' assert isclose(result.candidates[c_evans].transfers, -1000) assert isclose(result.candidates[c_thomson].transfers, 1000) # Stage 10 result = counter.step() - assert result.comment == 'Exclusion of Evans' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Evans' assert isclose(result.candidates[c_evans].transfers, -1234) assert isclose(result.exhausted.transfers, 1234) # Stage 11 result = counter.step() - assert result.comment == 'Exclusion of Evans' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Evans' assert isclose(result.candidates[c_evans].transfers, -804) assert isclose(result.candidates[c_thomson].transfers, 402) assert isclose(result.candidates[c_white].transfers, 402) # Stage 12 result = counter.step() - assert result.comment == 'Exclusion of Evans' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Evans' assert isclose(result.candidates[c_evans].transfers, -1785) assert isclose(result.candidates[c_white].transfers, 1190) assert isclose(result.exhausted.transfers, 595) # Stage 13 result = counter.step() - assert result.comment == 'Exclusion of Evans' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Evans' assert isclose(result.candidates[c_evans].transfers, -402) assert isclose(result.candidates[c_thomson].transfers, 402) # Stage 14 result = counter.step() - assert result.comment == 'Exclusion of Evans' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Evans' assert isclose(result.candidates[c_evans].transfers, -595) assert isclose(result.candidates[c_white].transfers, 595) @@ -186,19 +199,22 @@ def test_prsa1(): # Stage 15 result = counter.step() - assert result.comment == 'Exclusion of Thomson' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Thomson' assert isclose(result.candidates[c_thomson].transfers, -5000) assert isclose(result.exhausted.transfers, 5000) # Stage 16 result = counter.step() - assert result.comment == 'Exclusion of Thomson' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Thomson' assert isclose(result.candidates[c_thomson].transfers, -2468) assert isclose(result.exhausted.transfers, 2468) # Stage 17 result = counter.step() - assert result.comment == 'Exclusion of Thomson' + assert result.stage_kind == 'Exclusion of' + assert result.comment == 'Thomson' assert isclose(result.candidates[c_thomson].transfers, -1206) assert isclose(result.candidates[c_white].transfers, 1206)