|
| 1 | +# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries |
| 2 | +# SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +import math |
| 5 | +import random |
| 6 | +import tkinter as tk |
| 7 | +import time |
| 8 | +from palette import Palette |
| 9 | + |
| 10 | +class DataNumber: |
| 11 | + active_bin = None |
| 12 | + |
| 13 | + @classmethod |
| 14 | + def reset_active_bin(cls): |
| 15 | + """Reset the class-level active bin tracker""" |
| 16 | + cls.active_bin = None |
| 17 | + |
| 18 | + def __init__(self, x: int, y: int, canvas: tk.Canvas, base_size: int = 35, palette=Palette): |
| 19 | + """ |
| 20 | + Initialize a data number for macrodata refinement |
| 21 | + """ |
| 22 | + self.num = random.randint(0, 9) |
| 23 | + self.home_x = x |
| 24 | + self.home_y = y |
| 25 | + self.x = x |
| 26 | + self.y = y |
| 27 | + self.mouse_offset_x = 0 |
| 28 | + self.mouse_offset_y = 0 |
| 29 | + self.palette = palette |
| 30 | + self.color = self.palette.FG |
| 31 | + self.alpha = 255 |
| 32 | + self.base_size = base_size |
| 33 | + self.size = base_size |
| 34 | + self.refined = False |
| 35 | + self.bin_it = False |
| 36 | + self.bin = None |
| 37 | + self.bin_pause_time = 2 |
| 38 | + self.bin_pause = self.bin_pause_time |
| 39 | + self.canvas = canvas |
| 40 | + self.text_id = self.canvas.create_text( |
| 41 | + self.x, self.y, |
| 42 | + text=str(self.num), |
| 43 | + font=('Courier', self.size), |
| 44 | + fill=self.color, |
| 45 | + anchor='center' |
| 46 | + ) |
| 47 | + self.needs_refinement = False |
| 48 | + self.wiggle_offset_x = 0 |
| 49 | + self.wiggle_offset_y = 0 |
| 50 | + |
| 51 | + def refine(self, bin_obj=None, bins_list=None): |
| 52 | + """ |
| 53 | + Mark this number for refinement and assign it to a bin. |
| 54 | + """ |
| 55 | + if bin_obj is not None: |
| 56 | + if bin_obj.is_full(): |
| 57 | + return False |
| 58 | + target_bin = bin_obj |
| 59 | + elif bins_list is not None: |
| 60 | + target_bin = self.get_non_full_bin_for_position(bins_list) |
| 61 | + if target_bin is None: |
| 62 | + return False |
| 63 | + else: |
| 64 | + raise ValueError("Either bin_obj or bins_list must be provided") |
| 65 | + self.bin_it = True |
| 66 | + if DataNumber.active_bin is None: |
| 67 | + DataNumber.active_bin = target_bin |
| 68 | + self.bin = target_bin |
| 69 | + else: |
| 70 | + if DataNumber.active_bin.is_full(): |
| 71 | + DataNumber.active_bin = target_bin |
| 72 | + self.bin = DataNumber.active_bin |
| 73 | + return True |
| 74 | + |
| 75 | + def get_non_full_bin_for_position(self, bins_list): |
| 76 | + """ |
| 77 | + Determine which available bin should open based on the position of this number. |
| 78 | + """ |
| 79 | + non_full_bins = [bin_obj for bin_obj in bins_list if not bin_obj.is_full()] |
| 80 | + if not non_full_bins: |
| 81 | + return None |
| 82 | + screen_width = self.canvas.winfo_width() |
| 83 | + original_bin_index = self.get_bin_index_for_position(screen_width, len(bins_list)) |
| 84 | + closest_bin = None |
| 85 | + min_distance = float('inf') |
| 86 | + for bin_obj in non_full_bins: |
| 87 | + distance = abs(bin_obj.i - original_bin_index) |
| 88 | + if distance < min_distance: |
| 89 | + min_distance = distance |
| 90 | + closest_bin = bin_obj |
| 91 | + return closest_bin |
| 92 | + |
| 93 | + def get_bin_index_for_position(self, screen_width, num_bins): |
| 94 | + """ |
| 95 | + Get the bin index that corresponds to this number's position |
| 96 | + """ |
| 97 | + bin_width = screen_width / num_bins |
| 98 | + bin_index = int(self.x / bin_width) |
| 99 | + bin_index = max(0, min(bin_index, num_bins - 1)) |
| 100 | + return bin_index |
| 101 | + |
| 102 | + def go_bin(self): |
| 103 | + """Move toward the bin for refinement""" |
| 104 | + if self.bin: |
| 105 | + self.bin.open() |
| 106 | + if self.bin_pause <= 0: |
| 107 | + dx = self.bin.x - self.x |
| 108 | + dy = self.bin.y - self.y |
| 109 | + distance = math.sqrt(dx*dx + dy*dy) |
| 110 | + if distance < 20: |
| 111 | + self.alpha = int(255 * (distance / 20)) |
| 112 | + if distance < 3: |
| 113 | + self.wiggle_offset_x = 0 |
| 114 | + self.wiggle_offset_y = 0 |
| 115 | + self.mouse_offset_x = 0 |
| 116 | + self.mouse_offset_y = 0 |
| 117 | + self.bin.add_number() |
| 118 | + self.reset() |
| 119 | + return |
| 120 | + easing = max(0.03, min(0.1, 5.0 / distance)) |
| 121 | + self.x += dx * easing |
| 122 | + self.y += dy * easing |
| 123 | + fade_start_distance = self.distance(self.home_x, self.home_y, |
| 124 | + self.bin.x, self.bin.y) * 0.4 |
| 125 | + current_distance = self.distance(self.x, self.y, self.bin.x, self.bin.y) |
| 126 | + if distance >= 20: |
| 127 | + self.alpha = self.map_value(current_distance, fade_start_distance, 20, 255, 55) |
| 128 | + self.update_display() |
| 129 | + if hasattr(self.bin, 'level_elements'): |
| 130 | + for element_id in self.bin.level_elements.values(): |
| 131 | + self.canvas.tag_raise(self.text_id, element_id) |
| 132 | + self.bin.last_refined_time = int(time.time() * 1000) |
| 133 | + else: |
| 134 | + self.bin_pause -= 1 |
| 135 | + if self.bin_pause > 0: |
| 136 | + pulse_size = self.base_size * (1.0 + 0.5 * |
| 137 | + (1.0 - (self.bin_pause / self.bin_pause_time))) |
| 138 | + self.set_size(pulse_size) |
| 139 | + if hasattr(self.bin, 'level_elements'): |
| 140 | + for element_id in self.bin.level_elements.values(): |
| 141 | + self.canvas.tag_raise(self.text_id, element_id) |
| 142 | + |
| 143 | + def reset(self): |
| 144 | + """Reset the number after being binned.""" |
| 145 | + self.num = random.randint(0, 9) |
| 146 | + self.x = self.home_x |
| 147 | + self.y = self.home_y |
| 148 | + self.wiggle_offset_x = 0 |
| 149 | + self.wiggle_offset_y = 0 |
| 150 | + self.mouse_offset_x = 0 |
| 151 | + self.mouse_offset_y = 0 |
| 152 | + self.refined = False |
| 153 | + self.bin_it = False |
| 154 | + self.bin = None |
| 155 | + self.color = self.palette.FG |
| 156 | + self.alpha = 255 |
| 157 | + self.bin_pause = self.bin_pause_time |
| 158 | + self.update_display() |
| 159 | + still_active = False |
| 160 | + if not still_active and DataNumber.active_bin is not None: |
| 161 | + DataNumber.active_bin = None |
| 162 | + |
| 163 | + def go_home(self): |
| 164 | + """Move the number back to its home position with easing.""" |
| 165 | + self.x = self.lerp(self.x, self.home_x, 0.1) |
| 166 | + self.y = self.lerp(self.y, self.home_y, 0.1) |
| 167 | + self.size = self.lerp(self.size, self.base_size, 0.1) |
| 168 | + self.update_display() |
| 169 | + |
| 170 | + def set_size(self, sz): |
| 171 | + """Set the size of the number.""" |
| 172 | + self.size = sz |
| 173 | + self.update_display() |
| 174 | + |
| 175 | + def turn(self, new_color): |
| 176 | + """Change the color of the number.""" |
| 177 | + self.color = new_color |
| 178 | + self.update_display() |
| 179 | + |
| 180 | + def inside(self, x1, y1, x2, y2): |
| 181 | + """Check if this number is inside the given rectangle.""" |
| 182 | + return ( |
| 183 | + self.x > min(x1, x2) and |
| 184 | + self.x < max(x1, x2) and |
| 185 | + self.y > min(y1, y2) and |
| 186 | + self.y < max(y1, y2) |
| 187 | + ) |
| 188 | + |
| 189 | + def show(self): |
| 190 | + """Update the display of this number.""" |
| 191 | + self.update_display() |
| 192 | + |
| 193 | + def update_display(self): |
| 194 | + """Update the text display with current properties and improved alpha handling""" |
| 195 | + if self.bin_it: |
| 196 | + digit_size = self.lerp(self.size, self.size * 2.5, |
| 197 | + self.map_value(self.bin_pause, self.bin_pause_time, 0, 0, 1)) |
| 198 | + else: |
| 199 | + digit_size = self.size |
| 200 | + font = ('Courier', int(digit_size)) |
| 201 | + clamped_alpha = max(0, min(255, self.alpha)) |
| 202 | + if clamped_alpha == 0: |
| 203 | + self.canvas.itemconfig(self.text_id, state='hidden') |
| 204 | + return |
| 205 | + else: |
| 206 | + self.canvas.itemconfig(self.text_id, state='normal') |
| 207 | + if clamped_alpha < 255: |
| 208 | + bg_color = self.palette.BG |
| 209 | + fg_color = self.color |
| 210 | + alpha_ratio = clamped_alpha / 255.0 |
| 211 | + if alpha_ratio < 0.05: |
| 212 | + display_color = self.blend_colors(bg_color, fg_color, 0.05) |
| 213 | + else: |
| 214 | + display_color = self.blend_colors(bg_color, fg_color, alpha_ratio) |
| 215 | + else: |
| 216 | + display_color = self.color |
| 217 | + self.canvas.itemconfig(self.text_id, |
| 218 | + text=str(self.num), |
| 219 | + font=font, |
| 220 | + fill=display_color) |
| 221 | + if not hasattr(self, 'wiggle_offset_x'): |
| 222 | + self.wiggle_offset_x = 0 |
| 223 | + if not hasattr(self, 'wiggle_offset_y'): |
| 224 | + self.wiggle_offset_y = 0 |
| 225 | + if not hasattr(self, 'mouse_offset_x'): |
| 226 | + self.mouse_offset_x = 0 |
| 227 | + if not hasattr(self, 'mouse_offset_y'): |
| 228 | + self.mouse_offset_y = 0 |
| 229 | + smooth_wiggle_x = round(self.wiggle_offset_x * 10) / 10 |
| 230 | + smooth_wiggle_y = round(self.wiggle_offset_y * 10) / 10 |
| 231 | + smooth_mouse_x = round(self.mouse_offset_x * 10) / 10 |
| 232 | + smooth_mouse_y = round(self.mouse_offset_y * 10) / 10 |
| 233 | + display_x = self.x + smooth_wiggle_x + smooth_mouse_x |
| 234 | + display_y = self.y + smooth_wiggle_y + smooth_mouse_y |
| 235 | + self.canvas.coords(self.text_id, display_x, display_y) |
| 236 | + |
| 237 | + def resize(self, new_x, new_y): |
| 238 | + """Update the home position when the window is resized.""" |
| 239 | + self.home_x = new_x |
| 240 | + self.home_y = new_y |
| 241 | + |
| 242 | + def show_wiggle(self, proximity_factor=0): |
| 243 | + """Make the number threatening""" |
| 244 | + if self.needs_refinement and not self.bin_it: |
| 245 | + original_x, original_y = self.x, self.y |
| 246 | + smooth_x = round(self.wiggle_offset_x * 10) / 10 |
| 247 | + smooth_y = round(self.wiggle_offset_y * 10) / 10 |
| 248 | + self.x += smooth_x |
| 249 | + self.y += smooth_y |
| 250 | + original_color = self.color |
| 251 | + base_pulse = 0.7 |
| 252 | + wave1 = math.sin(time.time() * 0.9) * 0.15 |
| 253 | + wave2 = math.sin(time.time() * 1.8) * 0.05 |
| 254 | + highlight_intensity = base_pulse + wave1 + wave2 + (proximity_factor * 0.2) |
| 255 | + highlight_intensity = max(0.6, min(1.0, highlight_intensity)) |
| 256 | + if highlight_intensity > 0.82: |
| 257 | + self.color = self.palette.SELECT |
| 258 | + else: |
| 259 | + blend_amount = (highlight_intensity - 0.6) / 0.22 |
| 260 | + self.color = self.blend_colors(self.palette.FG, self.palette.SELECT, blend_amount) |
| 261 | + self.update_display() |
| 262 | + self.x, self.y = original_x, original_y |
| 263 | + self.color = original_color |
| 264 | + |
| 265 | + @staticmethod |
| 266 | + def lerp(start, end, amt): |
| 267 | + """Linear interpolation between start and end by amt.""" |
| 268 | + return start + (end - start) * amt |
| 269 | + |
| 270 | + @staticmethod |
| 271 | + def map_value(value, start1, stop1, start2, stop2): |
| 272 | + """Re-maps a number from one range to another.""" |
| 273 | + if stop1 == start1: |
| 274 | + return start2 |
| 275 | + return start2 + (stop2 - start2) * ((value - start1) / (stop1 - start1)) |
| 276 | + |
| 277 | + @staticmethod |
| 278 | + def distance(x1, y1, x2, y2): |
| 279 | + """Calculate distance between two points.""" |
| 280 | + return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) |
| 281 | + |
| 282 | + @staticmethod |
| 283 | + def hex_to_rgb(hex_color): |
| 284 | + """Convert hex color to RGB values.""" |
| 285 | + # Strip the # if it exists |
| 286 | + hex_color = hex_color.lstrip('#') |
| 287 | + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) |
| 288 | + |
| 289 | + @staticmethod |
| 290 | + def rgb_to_hex(rgb): |
| 291 | + """Convert RGB tuple to hex color.""" |
| 292 | + return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}" |
| 293 | + |
| 294 | + @staticmethod |
| 295 | + def blend_colors(color1, color2, ratio): |
| 296 | + """Blend two colors based on ratio (0-1).""" |
| 297 | + c1 = DataNumber.hex_to_rgb(color1) |
| 298 | + c2 = DataNumber.hex_to_rgb(color2) |
| 299 | + blended = tuple(int(c1[i] + (c2[i] - c1[i]) * ratio) for i in range(3)) |
| 300 | + return DataNumber.rgb_to_hex(blended) |
0 commit comments