-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhtml_util.py
More file actions
247 lines (197 loc) · 9.23 KB
/
html_util.py
File metadata and controls
247 lines (197 loc) · 9.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
#!/usr/bin/env python3
"""
Utilities for generating HTML or preparing data for HTML templates.
"""
from abiflib.util import find_ballot_type
import colorsys
import re
def format_notice_paragraphs(text):
"""Convert paragraph breaks in notice text to HTML paragraphs.
Converts double newlines (\n\n) to <p> tags with modest styling.
Designed for reusable formatting across all voting methods.
Args:
text (str): Plain text with \n\n paragraph breaks
Returns:
str: HTML with <p> tags for paragraph formatting
"""
if not text:
return text
# Split on double newlines to get paragraphs
paragraphs = text.split('\n\n')
# Wrap each paragraph in <p> tags with modest styling
html_paragraphs = []
for i, paragraph in enumerate(paragraphs):
if paragraph.strip(): # Skip empty paragraphs
margin_style = 'margin-bottom: 0.4em; margin-top: 0.4em;'
html_paragraphs.append(f'<p style="{margin_style}">{paragraph.strip()}</p>')
return ''.join(html_paragraphs)
def generate_golden_angle_palette(count=250, start_hex='#d0ffce',
initial_colors=None,
master_list_size=250):
"""Generates a list of visually distinct colors, with an option for a custom start.
If an `initial_colors` list is provided, it will be used as the
start of the palette, and seed the rest of the list from the hue
of the last color in that list. Otherwise, gnerate a full palette
starting from `start_hex` using the golden angle (137.5 degrees)
for hue rotation. Saturation and value are adjusted based on a
`master_list_size` to ensure colors are always consistent
regardless of the total count requested.
Args:
count (int): The total number of colors to generate.
start_hex (str): The starting hex color if `initial_colors` is not given.
initial_colors (list[str], optional): A list of hex colors to start the
palette with. Defaults to None.
master_list_size (int): The reference size for consistent generation.
Returns:
list[str]: A list of color strings in hex format.
"""
colors_hex = []
start_index = 0
if initial_colors:
# Start with the provided hand-picked colors.
colors_hex.extend(initial_colors)
if count <= len(colors_hex):
return colors_hex[:count]
# The algorithm will start generating after the initial colors.
start_index = len(colors_hex)
# The new starting point is the last of the initial colors.
start_hex = initial_colors[-1]
if not start_hex.startswith('#') or len(start_hex) != 7:
raise ValueError("start_hex must be in #RRGGBB format.")
# --- 1. Convert the starting hex color to its HSV representation ---
start_r = int(start_hex[1:3], 16) / 255.0
start_g = int(start_hex[3:5], 16) / 255.0
start_b = int(start_hex[5:7], 16) / 255.0
start_h, start_s, start_v = colorsys.rgb_to_hsv(start_r, start_g, start_b)
# --- 2. Generate the rest of the palette ---
golden_angle_increment = 137.5 / 360.0
# Loop from the start_index to the desired total count.
for i in range(start_index, count):
# The hue jump is based on the color's position relative to the start.
# This ensures the spiral continues correctly from the initial colors.
hue_jump_index = i - start_index
hue = (start_h + (hue_jump_index + 1) * golden_angle_increment) % 1.0
# Vary saturation and value based on the color's absolute index.
# This maintains consistency across different list lengths.
saturation = start_s + (i / master_list_size) * 0.1
value = start_v - (i / master_list_size) * 0.15
# Ensure saturation and value stay within the valid 0-1 range.
saturation = max(0, min(1, saturation))
value = max(0, min(1, value))
# Convert the new HSV color back to RGB.
r, g, b = colorsys.hsv_to_rgb(hue, saturation, value)
# Convert RGB to a hex string.
hex_color = '#{:02x}{:02x}{:02x}'.format(
int(r * 255), int(g * 255), int(b * 255)
)
colors_hex.append(hex_color)
return colors_hex
def generate_candidate_colors(candidates):
"""
Generates a dictionary mapping candidate keys to hex color codes.
:param candidates: A list of candidate keys (e.g., ['C1', 'C2', 'C3']).
:return: A dictionary like {'C1': '#d0ffce', 'C2': '#cefff9', ...}.
"""
colors = generate_golden_angle_palette(count=len(candidates) + 5,
initial_colors=[
'#d0ffce', '#cee1ff', '#ffcece', '#ffeab9']
)
colordict = {}
for i, cand in enumerate(candidates):
colordict[cand] = colors[i]
return colordict
def escape_css_selector(s):
"""
Escapes a string to be used as a CSS selector, replacing invalid characters with an underscore.
"""
# This regex finds any character that is not a letter, number, underscore, or hyphen.
return re.sub(r'[^a-zA-Z0-9_-]', '_', s)
def add_html_hints_to_stardict(scores, stardict, colordict=None):
"""
Add HTML presentation hints to a STAR voting results dictionary.
Adds color information, scaled scores, and CSS selector strings
for template rendering.
Args:
scores: Score results with ranklist
stardict: STAR voting results dictionary to enhance
colordict: Optional pre-generated color mapping
Returns:
dict: Enhanced stardict with HTML presentation data
"""
retval = stardict
retval['starscaled'] = {}
retval['colordict'] = {}
retval['colorlines'] = {}
# Use provided colordict or generate from scores ranklist
if colordict:
# Reorder the provided colordict to match scores ranklist order
colors = {cand: colordict.get(cand, '#cccccc') for cand in scores['ranklist']}
else:
colors = generate_candidate_colors(scores['ranklist'])
retval['colordict'] = colors
curstart = 1
for i, candtok in enumerate(scores['ranklist']):
retval['starscaled'][candtok] = round(
retval['canddict'][candtok]['scaled_score'])
selline = ", ".join(".s%02d" % j for j in range(
curstart, retval['starscaled'][candtok] + curstart))
safe_candtok = escape_css_selector(candtok)
retval['colorlines'][candtok] = f".g{i + 1}"
if selline:
retval['colorlines'][candtok] += ", " + selline
retval['colorlines'][candtok] += " { color: " + colors[candtok] + "; }"
curstart += retval['starscaled'][candtok]
try:
retval['starratio'] = round(
retval['total_all_scores'] / retval['scaled_total'])
except ZeroDivisionError:
retval['starratio'] = 0
return retval
def get_method_ordering(abifmodel, default_methods=None):
"""
Determine the optimal ordering of voting methods for display.
Based on UX Step 3: puts declared tally_method first, otherwise orders
based on detected ballot type.
Args:
abifmodel: The ABIF model with metadata and ballot data
default_methods: Optional list of available methods to order
Returns:
list: Ordered method names (e.g., ['IRV', 'FPTP', 'approval', 'STAR', 'wlt'])
"""
if default_methods is None:
default_methods = ['FPTP', 'IRV', 'approval', 'STAR', 'wlt']
# Check if tally_method is declared in metadata
declared_method = None
if 'metadata' in abifmodel and 'tally_method' in abifmodel['metadata']:
declared_method = abifmodel['metadata']['tally_method']
# Map declared methods to internal method names
method_mapping = {
'IRV': 'IRV',
'FPTP': 'FPTP',
'approval': 'approval',
'STAR': 'STAR',
'pairwise': 'wlt',
'Condorcet': 'wlt',
'Copeland': 'wlt'
}
mapped_method = method_mapping.get(declared_method)
if mapped_method and mapped_method in default_methods:
# Put declared method first, then standard order (removing duplicates)
standard_order = ['FPTP', 'approval', 'IRV', 'STAR', 'wlt']
ordered_methods = [mapped_method]
ordered_methods.extend([m for m in standard_order if m != mapped_method and m in default_methods])
return ordered_methods
# No valid declared method - order based on detected ballot type
ballot_type = find_ballot_type(abifmodel)
if ballot_type == 'ranked':
# Ranked ballots: IRV → FPTP → Approval → STAR → Condorcet
preferred_order = ['IRV', 'FPTP', 'approval', 'STAR', 'wlt']
else:
# Non-ranked ballots: FPTP → Approval → IRV → STAR → Condorcet
preferred_order = ['FPTP', 'approval', 'IRV', 'STAR', 'wlt']
# Filter to only include methods that are actually available
ordered_methods = [method for method in preferred_order if method in default_methods]
# Add any remaining methods not in our preferred order
remaining = [method for method in default_methods if method not in ordered_methods]
ordered_methods.extend(remaining)
return ordered_methods