diff --git a/app.py b/app.py index 0474504..b90fc30 100644 --- a/app.py +++ b/app.py @@ -886,39 +886,72 @@ def get_admin_expense_summary(): -def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str: - # Stabilny hash -> int +import hashlib, colorsys +from typing import List, Tuple + +# Dozwolone segmenty hue (stopnie). Wykluczamy żółcie (50–70°) i jasną zieleń (80–160°). +# Zostawiamy wąski pas "butelkowej" zieleni (165–185°) oraz ciemny pomarańcz (18–40°). +ALLOWED_HUE_SEGMENTS: List[Tuple[int, int]] = [ + (0, 10), # czerwienie + (18, 40), # ciemny pomarańcz + (165, 185), # butelkowa zieleń + (200, 260), # chłodne niebieskie + (270, 320), # fiolety + (330, 360), # czerwienie/karmin +] + +def rotl(x: int, r: int, bits: int = 128) -> int: + r %= bits + return ((x << r) | (x >> (bits - r))) & ((1 << bits) - 1) + +def pick_hue_from_segments(mix: int) -> int: + # Wybierz segment deterministycznie i rozłóż równomiernie w jego zakresie + seg_idx = mix % len(ALLOWED_HUE_SEGMENTS) + a, b = ALLOWED_HUE_SEGMENTS[seg_idx] + span = (b - a) % 360 + # Użyj kolejnej porcji bitów do pozycji wewnątrz segmentu + t = (rotl(mix, 23) % 1000) / 1000.0 # [0,1) + hue = int(a + t * span) % 360 + return hue + +def category_to_color(name: str, min_hue_gap_deg: int = 14) -> str: hv = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16) - - # Proste mieszanie bitów, by uniknąć lokalnych skupień - def rotl(x, r, bits=128): - r %= bits - return ((x << r) | (x >> (bits - r))) & ((1 << bits) - 1) - mix = hv ^ rotl(hv, 37) ^ rotl(hv, 73) ^ rotl(hv, 91) - # Pełne pokrycie koła barw 0..360 - hue_deg = mix % 360 + # Wybór hue tylko z dozwolonych segmentów + hue_deg = pick_hue_from_segments(mix) - # Odpychanie lokalne po hue, by podobne nazwy nie lądowały zbyt blisko - gap = (rotl(mix, 17) % (2*min_hue_gap_deg)) - min_hue_gap_deg # [-gap, +gap] + # Lokalne odpychanie w ramach segmentu, by podobne nazwy nie dawały niemal tego samego koloru + gap = (rotl(mix, 17) % (2*min_hue_gap_deg)) - min_hue_gap_deg hue_deg = (hue_deg + gap) % 360 - # DARK profil: niższa jasność i nieco mniejsza saturacja - s = 0.70 - l = 0.45 + # Bardzo ciemny profil, unikamy prawie czarnych + # Saturation ograniczone, żeby nie było neonów; Lightness niskie, ale > 0.30 + s_base, l_base = 0.66, 0.37 + s_var = ((rotl(mix, 29) % 7) - 3) / 100.0 # ±0.03 + l_var = ((rotl(mix, 53) % 9) - 4) / 100.0 # ±0.04 - # Wąska wariacja, żeby uniknąć „neonów” i zachować spójność - s_var = ((rotl(mix, 29) % 5) - 2) / 100.0 # ±0.02 - l_var = ((rotl(mix, 53) % 7) - 3) / 100.0 # ±0.03 - s = min(0.76, max(0.62, s + s_var)) - l = min(0.50, max(0.40, l + l_var)) + s = min(0.72, max(0.58, s_base + s_var)) + l = min(0.42, max(0.32, l_base + l_var)) - # Konwersja HLS->RGB (colorsys: H,L,S w [0..1]) - h = hue_deg / 360.0 + # Dodatkowa korekta: dla niebiesko-fioletowych utrzymaj nieco wyższą L, żeby nie wpadały w czerń + if 200 <= hue_deg <= 320: + l = max(l, 0.35) + + # Konwersja HLS->RGB (colorsys: h,l,s w [0..1]) + h = (hue_deg % 360) / 360.0 r, g, b = colorsys.hls_to_rgb(h, l, s) - return f"#{int(round(r*255)):02x}{int(round(g*255)):02x}{int(round(b*255)):02x}" + # Unikaj prawie czarnych po RGB (wszystkie kanały < ~28) + rr, gg, bb = int(round(r*255)), int(round(g*255)), int(round(b*255)) + if rr < 28 and gg < 28 and bb < 28: + # Podbij minimalnie L, zachowując hue/sat + l = min(0.44, l + 0.04) + r, g, b = colorsys.hls_to_rgb(h, l, s) + rr, gg, bb = int(round(r*255)), int(round(g*255)), int(round(b*255)) + + return f"#{rr:02x}{gg:02x}{bb:02x}" + def get_total_expenses_grouped_by_category( show_all, range_type, start_date, end_date, user_id, category_id=None