import math import random import statistics import time import json import os.path import argparse import sys import urllib.request import re from datetime import datetime from html.parser import HTMLParser import plotly.graph_objects as go from plotly.subplots import make_subplots from PIL import Image # consts TITLE = "SGDQ 2026" SUBTITLE = "as rated by /v/" USE_LEGEND = False # display the legend with markers TITLE_OFFSET = 0 # 2.5 # to-do: dynamically set this to how many columns are set with legend UI_SCALE = 1.0 DATE_OFFSET = (60 * 60 * 8) # les constant consts AUX_MODE = None # total | markers | None SORT_BY = None # sort runs by the values CUTOFF_SECONDS = 0 # 60 * 5 MODE = "scatter" FILTER_BY = None # filters by markers PRINT_STATS = False # more constant consts TIMESTAMP = str(int(time.time_ns()/1000/1000)) IN_QUEUE_FILE = "./data/queue.json" IN_RATINGS_FILE = "./data/ratings.json" OUT_FILE_TIMESTAMP = f'./images/{TIMESTAMP}.png' OUT_FILE = f'./images/ratings[{SORT_BY or AUX_MODE or "chronological"}].png' MIN_COLUMNS = 2 # looks better if there's more than one column with the legend CULL_SINGLETON_MARKERS = False # remove any marker that only has 1 entry COLOR_BY = "mean" # color by this stat's value FADE_BY_STDEV = True # fade outliers LINES = ["mean_smart", "median"] # show mean and median lines (or stdev too) USE_LATEX = False # never got this to work DROP_Z_S = False RE_RATING = re.compile(r"(\b[A-HJ-Zz]+(?:[+-]|\b))") RE_RATING_NEWLINE = re.compile(r"(\b[A-HJ-Zz]+(?:[+-]|\b))\n") # just the above, but for newlines IMAGE_CACHE = {} COL_WIDTH = 4.0 ROW_HEIGHT = 0.5 # gimmicks REVERSE = False # classic reverse tier lists ZOOMER = False # sets scale to zoomer-friendly ratings # theme colors DARK = True COLORS = { "BACKGROUND": "#0c0c0c", "BAND0": "#101010", "BAND1": "#080808", "LINE": "#181818", "MEAN": "#404040", "STDEV": "#404040", "TEXT": "#e0e0e0", "STATS": "#606060", "MEDIAN": "#AA00AA", "RATINGS": [ ( 0.0, (0.2, 0.2, 0.2)), ( 3.5, (0.6, 0.2, 0.2)), ( 4.5, (1.0, 0.2, 0.2)), ( 6.5, (1.0, 1.0, 0.2)), ( 7.5, (0.2, 1.0, 0.2)), ( 8.5, (0.2, 1.0, 1.0)), (10.0, (1.0, 1.0, 1.0)), ], "SPECIAL": (0.6, 0.2, 1.0), "BROWN": (139.0/255.0,69.0/255.0,19.0/255.0), "BARBIE": (244.0/255.0, 33.0/255.0, 138.0/255.0), "ROSE AND CAMELLIA": (0.8705882352941177, 0.6313725490196078, 0.5764705882352941), } if DARK else { "BACKGROUND": "#ffffff", "BAND0": "#f0f0f0", "BAND1": "#f8f8f8", "LINE": "#e8e8e8", "MEAN": "#404040", "STDEV": "#404040", "TEXT": "#404040", "STATS": "#a0a0a0", "MEDIAN": "#AA00AA", "RATINGS": [ ( 0.0, (0.0, 0.0, 0.0)), ( 3.5, (0.4, 0.0, 0.0)), ( 4.5, (0.9, 0.0, 0.0)), ( 6.5, (0.8, 0.8, 0.0)), ( 7.5, (0.0, 0.9, 0.0)), ( 8.5, (0.0, 0.8, 0.8)), (10.0, (0.7, 0.7, 0.7)), ], "SPECIAL": (0.4, 0.0, 0.8), "BROWN": (139.0/255.0,69.0/255.0,19.0/255.0), "BARBIE": (244.0/255.0, 33.0/255.0, 138.0/255.0), "ROSE AND CAMELLIA": (0.8705882352941177, 0.6313725490196078, 0.5764705882352941), } """ if DARK: Plot.style.use("dark_background") """ SCORES = { "K": 0, "T": 0, "ZZZZ": 2.9, "ZZZ-": 3.0, "ZZZ": 3.1, "ZZ": 3.15, "Z-": 3.2, "Z": 3.25, "DNF": 3.25, "L": 3.25, "N": 3.25, "FFF": 3.5, "FF": 3.7, "F-": 3.7, "F": 4.0, "F+": 4.3, "E": 4.5, "D-": 4.7, "D": 5.0, "D+": 5.3, "C-": 5.7, "C": 6.0, "C+": 6.3, "B-": 6.7, "B": 7.0, "B+": 7.3, "A-": 7.7, "A": 8.0, "A+": 8.3, "S-": 8.7, "S": 9.0, "P": 9.0, "S+": 9.3, "SS": 9.3, "SSS": 9.5, "W": 9.7, "SSS+": 9.8, "SSSS": 9.9, "HH": 10 } TOTAL_SCORES = { name: 0 for name in SCORES.keys() } if SORT_BY is not None: COLOR_BY = SORT_BY # runtime globals THREADS = {} MARKERS = {} REVERSE_MARKERS = {} def add_marker( name, tag, color=COLORS["TEXT"], reverse=None, zoomer=None ): MARKERS[name] = { "tag": tag, "color": color, "count": 0, "reverse": reverse, "zoomer": zoomer, } add_marker("girl", tag="!", reverse="femcel", zoomer="gyatt") add_marker("foid", tag="...", reverse="sex worker", zoomer="skibidi") add_marker("tranny", tag="*", reverse="real woman", zoomer="fr") add_marker("ladyboy", tag="¿", reverse="tomboy") add_marker("black", tag="&", reverse="white", zoomer="kang") add_marker("biohazard", tag="#") # fatales add_marker("male", tag="♂") # fatales add_marker("female", tag="♀") # fatales add_marker("BOOBS", tag="( Y )") # memetic add_marker("vt", tag="^") # rarely used add_marker("amogus", tag=" sus") # rarely used add_marker("race", tag="@") # unused now add_marker("savestated", tag="\\") # rarely used, should be invalid now #add_marker("trainwreck/cringekino", tag="%", reverse="flawless", zoomer="fanum tax") add_marker("trainwreck", tag="%", reverse="flawless", zoomer="fanum tax") add_marker("cringekino", tag="%", reverse="kinocringe", zoomer="fanum tax") add_marker("DNF/invalid", tag="$", reverse="WR", zoomer="ohio") add_marker("overestimate", tag=">", reverse="underestimate", zoomer="💀") add_marker("ad", tag="✡", reverse="organic", zoomer="sigma") add_marker("nonrun", tag="@", reverse="letsplay", zoomer="content") add_marker("neporun", tag="\\", reverse="indie", zoomer="collab") add_marker("it", tag="※", reverse="human", zoomer="it") #add_marker("ad/nonrun", tag="✡", reverse="organic", zoomer="sigma") def sample_image(image_path, px, py_local, min_x, max_x, min_y, max_y): if image_path not in IMAGE_CACHE: try: IMAGE_CACHE[image_path] = Image.open(image_path).convert('RGB') except Exception as e: print(f"Could not load {image_path}: {e}") IMAGE_CACHE[image_path] = None img = IMAGE_CACHE[image_path] if img is None: return None range_x = max_x - min_x if max_x > min_x else 1.0 range_y = max_y - min_y if max_y > min_y else 1.0 u = clamp((px - min_x) / range_x) v = clamp((py_local - min_y) / range_y) ix = int(u * (img.width - 1)) iy = int((1.0 - v) * (img.height - 1)) r, g, b = img.getpixel((ix, iy)) return (r / 255.0, g / 255.0, b / 255.0) # yuck class MyHTMLParser(HTMLParser): def __init__(self): self.str = "" super().__init__() def handle_starttag(self, tag, attrs): if tag == "br": self.str += "\n" def handle_data(self, data): self.str += data def curl(url): if url in THREADS: return THREADS[url] try: conn = urllib.request.urlopen(url) data = conn.read() data = data.decode() data = json.loads(data) conn.close() THREADS[url] = data return data except: return None # ick def parse_rating(comment): matches = RE_RATING_NEWLINE.findall(comment) binned = re.sub(r'>>\d+\n', "", comment) if len(matches) == 1: match = matches[0] if match[0] in "SABCDEFNTZKsabcdefntzk": return match, None matches = RE_RATING.findall(comment) if len(matches) <= 0: return None, binned if len(matches) >= 2: return None, binned match = matches[0] if match[0] not in "SABCDEFNTZKsabcdefntzk": return None, binned return match, None def fetch_ratings( queue=[] ): try: ratings = json.load(open(IN_RATINGS_FILE, "r", encoding='utf-8')) except Exception as e: ratings = {} raise e if not queue: try: queue = json.load(open(IN_QUEUE_FILE, "r", encoding='utf-8')) except Exception as e: print(str(e)) pass for entry in queue: name = entry["name"] post = entry["post"] if post == "" or post is None: continue url, _, post_no = post.partition("#p") prefix, __, thread = url.partition("/thread/") # remove training slash if thread[-1] == "/": thread = thread[:-1] # pick API """ if "p.ecker.tech" in prefix: url = f'https://p.ecker.tech/chan/browse/v/thread/{thread}/?update' else: url = f'https://a.4cdn.org/v/thread/{thread}.json' """ url = f'https://a.4cdn.org/v/thread/{thread}.json' post_no = int(post_no) if name not in ratings: ratings[name] = {} # coerse marker => markers if "marker" in entry and "markers" not in entry: entry["markers"] = [ entry["marker"] ] del entry["marker"] # in case an entry is already defined without these if "markers" in entry: ratings[name]["markers"] = entry["markers"] if "posts" not in ratings[name]: ratings[name]["posts"] = [] if post not in ratings[name]["posts"]: ratings[name]["posts"].append(post) if "ratings" not in ratings[name]: ratings[name]["ratings"] = {} if "binned" not in ratings[name]: ratings[name]["binned"] = {} if "times" not in ratings[name]: ratings[name]["times"] = {} print(f"Fetching {name}: {post}") data = curl(url) if data is None: print(f"404: {name}: {post}") continue re_link = re.compile(f">>>{post_no}<") # coerce to array if isinstance(data["posts"], dict): data["posts"] = data["posts"].values() for post in data["posts"]: if "com" not in post: continue if "no" not in post: continue com = post["com"] no = str(post["no"]) time = post["time"] match = re_link.search(com) if match is None and f'{no}' != f'{post_no}': continue if no not in ratings[name]["times"]: ratings[name]["times"][no] = time if no in ratings[name]["ratings"]: continue if no in ratings[name]["binned"]: continue parser = MyHTMLParser() parser.feed(com) com = parser.str rating, binned = parse_rating(com) if rating is not None: ratings[name]["ratings"][no] = rating else: ratings[name]["binned"][no] = binned for no in ratings[name]["ratings"]: if no not in ratings[name]["times"]: print("MISSING:", name, no) json.dump(ratings, open(IN_RATINGS_FILE, "w"), indent='\t') json.dump(ratings, open(IN_RATINGS_FILE, "w"), indent='\t') def stat_to_str(stat): l = [f"{stat['name']:25} ::"] l.append(f"n = {stat['count']:2},") l.append(f"mean = {stat['mean']:.2f},") l.append(f"ms = {stat['mean_smart']:.2f},") l.append(f"sd = {stat['stdev']:.2f},") l.append(f"med = {stat['median']:.1f}") return " ".join(l) # Plot related def rating_to_point(rating, count, RAND_PT_DX=1/5, RAND_PT_DY=1/10): dx = random.gauss(0, RAND_PT_DX) dy = random.gauss(0, RAND_PT_DY) if count < 6: dx = dx * (math.sqrt(count) / 6) px = abs(rating + dx) if rating >= 3.5: if px < 3.5 or px > 9.5: px = abs(rating + dx / 2) else: px = abs(rating - dx / 2) # clamp if px <= 3.1: px = 3.1 + abs(dx) / 2 dy = dy * 1.125 if px > 9.9: px = 9.9 - abs(dx) / 2 dy = dy * 1.125 py = dy return px, py def clamp(x, lo=0, hi=1): if x < lo: return lo if x > hi: return hi return x def li(v0, v1, a): return v0 * (1- clamp(a)) + v1 * a def lic(c0, c1, a): return tuple(li(v0,v1,a) for v0,v1 in zip(c0,c1)) def lerp( a, b, t ): return a + (b - a) * clamp(t) # interpolate between the closest defined points def rating_from_color_table( rating, table, distance=0 ): for i in range(len(table)-1): t0, t1 = table[i], table[i+1] if t0[0] <= rating <= t1[0]: alpha = (rating-t0[0]) / (t1[0]-t0[0]) c = lic(t0[1], t1[1], alpha) r = c[0] / (1 + distance) g = c[1] / (1 + distance) b = c[2] / (1 + distance) return (r, g, b) return (1.0, 0.0, 1.0) def rating_to_color(rating, stat): target = stat[COLOR_BY] mean = stat['mean'] stdev = stat['stdev'] markers = stat['markers'] table = COLORS["RATINGS"] if "kino" in markers: return COLORS["SPECIAL"] if "shart" in markers: return COLORS["BROWN"] if "french" in markers: RED = (1.0, 0.0, 0.0) WHITE = (1.0, 1.0, 1.0) BLUE = (0.0, 0.0, 1.0) distance = rating - mean / stdev if distance < -1.0: return BLUE if distance > 0.5: return RED return WHITE if "trans rights" in markers: #F5A9B8 BLUE = (0.3569, 0.8078, 0.9804) PINK = (0.9608, 0.6627, 0.7216) WHITE = (1.0, 1.0, 1.0) distance = rating # - mean / stdev if distance < 4.0: return BLUE if distance < 4.5: return PINK if distance > 7.0: return BLUE if distance > 6.0: return PINK return WHITE distance = 0 if stdev > 0: distance = (abs(rating - mean) / stdev) if distance < 2: distance = 0 distance = distance * 0.75 return rating_from_color_table( target, table, distance ) def title_format(s): if USE_LATEX: return s.replace('&', r'\&').replace("^", r'\^') return s def to_plotly_color(color_tuple): if isinstance(color_tuple, str): return color_tuple # if it's already a hex string return f"rgb({int(color_tuple[0]*255)}, {int(color_tuple[1]*255)}, {int(color_tuple[2]*255)})" def plot_sub_scatter(fig, stats, row, col): if DROP_Z_S: xticks = [("Bussin", 9), ("Mid Sheesh", 6), ("L", 4)] if ZOOMER else [("S", 9), ("A", 8), ("B", 7), ("C", 6), ("D", 5), ("F", 4)] lo, hi = xticks[-1][1] - 1, xticks[0][1] + 1 else: xticks = [("Bussin", 10), ("Mid Sheesh", 6), ("L", 3)] if ZOOMER else [("SSS", 10), ("S", 9), ("A", 8), ("B", 7), ("C", 6), ("D", 5), ("F", 4), ("Z", 3)] lo, hi = xticks[-1][1], xticks[0][1] fig.update_xaxes( side="top", range=[lo, hi], tickvals=[t[1] for t in xticks], ticktext=[f"{t[0]} " for t in xticks], showline=True, linewidth=1.5, linecolor=to_plotly_color(COLORS["LINE"]), mirror="allticks", ticks="inside", ticklen=5, tickwidth=1.5, tickcolor=to_plotly_color(COLORS["LINE"]), showgrid=False, zeroline=False, row=row, col=col ) for t_label, x_val in xticks: fig.add_annotation( x=x_val, y=0, text=f"{t_label} ", showarrow=False, yanchor="top", yshift=-10, font=dict(color=to_plotly_color(COLORS["TEXT"]), size=11), row=row, col=col ) fig.update_yaxes( range=[0, len(stats)], showticklabels=False, showline=True, linewidth=1.5, linecolor=to_plotly_color(COLORS["LINE"]), mirror=True, showgrid=False, zeroline=False, row=row, col=col ) # draw horizontal bands for y in range(len(stats)): col_band = COLORS["BAND0"] if y % 2 == 0 else COLORS["BAND1"] y_inv = len(stats) - y - 1 # flip fig.add_hrect(y0=y_inv, y1=y_inv+1, fillcolor=to_plotly_color(col_band), opacity=1, layer="below", line_width=0, row=row, col=col) # draw vertical lines for _, x in xticks: fig.add_vline(x=x, line_width=1.5, line_color=to_plotly_color(COLORS["LINE"]), layer="below", row=row, col=col) stats.reverse() # draw stat points all_xs, all_ys, all_colors, all_sizes, all_texts, all_customdata = [], [], [], [], [], [] line_traces = { "mean": {"x": [], "y": [], "color": to_plotly_color(COLORS["MEAN"])}, "mean_smart": {"x": [], "y": [], "color": to_plotly_color(COLORS["MEAN"])}, "median": {"x": [], "y": [], "color": to_plotly_color(COLORS["MEDIAN"])}, "stdev": {"x": [], "y": [], "color": to_plotly_color(COLORS["STDEV"])} } for y, stat in enumerate(stats): if stat is None: continue name = stat['name'] for marker in stat["markers"]: if marker in MARKERS: name += MARKERS[marker]["tag"] xs = [ p[0] for p in stat['points'] ] ys = [ p[1] + y + 0.35 for p in stat['points'] ] if stat['points']: stdev_mult = 1.5 min_x = stat['mean'] - (stat['stdev'] * stdev_mult) max_x = stat['mean'] + (stat['stdev'] * stdev_mult) pys = [p[1] for p in stat['points']] mean_y = statistics.mean(pys) stdev_y = statistics.pstdev(pys) if stdev_y == 0: stdev_y = 0.1 min_y = mean_y - (stdev_y * stdev_mult) max_y = mean_y + (stdev_y * stdev_mult) else: min_x, max_x, min_y, max_y = 3.1, 9.9, -0.35, 0.35 color_points = [] for rating, (px, py) in zip(stat['ratings'], stat['points']): c = None # to-do: data-driven loop if "french" in stat["markers"]: c = sample_image("./assets/french.png", px, py, min_x, max_x, min_y, max_y) elif "ToT" in stat["markers"]: c = sample_image("./assets/crying.png", px, py, min_x, max_x, min_y, max_y) elif "trans rights" in stat["markers"]: c = sample_image("./assets/trans.png", px, py, min_x, max_x, min_y, max_y) elif "pajeet" in stat["markers"]: c = sample_image("./assets/india.png", px, py, min_x, max_x, min_y, max_y) if c is None: c = rating_to_color(rating, stat) color_points.append(to_plotly_color(c)) all_xs.extend(xs) all_ys.extend(ys) all_colors.extend(color_points) all_sizes.extend([lerp(10, 4, stat['count']/60.0) * UI_SCALE] * len(xs)) if "scores" in stat: all_texts.extend([f">>{no}
Rating: {r}
Click to view" for r, no in stat['scores']]) all_customdata.extend(stat.get('urls', [])) fig.add_annotation(x=3.1, y=y+0.8, text=name, showarrow=False, xanchor="left", yanchor="middle",font=dict(color=to_plotly_color(COLORS["TEXT"]), size=8 * UI_SCALE), row=row, col=col) fig.add_annotation(x=9.9, y=y+0.8, text=str(stat['count']), showarrow=False, xanchor="right", yanchor="middle",font=dict(color=to_plotly_color(COLORS["STATS"]), size=8 * UI_SCALE), row=row, col=col) if "mean" in LINES: line_traces["mean"]["x"].extend([stat['mean'], stat['mean'], None]) line_traces["mean"]["y"].extend([y+0.1, y+0.9, None]) if "mean_smart" in LINES: line_traces["mean_smart"]["x"].extend([stat['mean_smart'], stat['mean_smart'], None]) line_traces["mean_smart"]["y"].extend([y+0.1, y+0.9, None]) if "median" in LINES: line_traces["median"]["x"].extend([stat['median'], stat['median'], None]) line_traces["median"]["y"].extend([y+0.1, y+0.9, None]) if "stdev" in LINES: line_traces["stdev"]["x"].extend([stat["mean"] - stat["stdev"], stat["mean"] - stat["stdev"], None, stat["mean"] + stat["stdev"], stat["mean"] + stat["stdev"], None]) line_traces["stdev"]["y"].extend([y+0.2, y+0.8, None, y+0.2, y+0.8, None]) for key, data in line_traces.items(): if data["x"]: fig.add_trace(go.Scatter( x=data["x"], y=data["y"], mode="lines", line=dict(color=data["color"], width=1.5), hoverinfo="skip", showlegend=False ), row=row, col=col) fig.add_trace( go.Scatter( x=all_xs, y=all_ys, mode='markers', marker=dict(size=all_sizes, color=all_colors, line=dict(width=0)), hovertext=all_texts, hoverinfo="text", customdata=all_customdata, showlegend=False ), row=row, col=col ) def plot_sub_boxplot(fig, stats, row, col): if DROP_Z_S: xticks = [("Bussin", 9), ("Mid Sheesh", 6), ("L", 4)] if ZOOMER else [("S", 9), ("A", 8), ("B", 7), ("C", 6), ("D", 5), ("F", 4)] lo, hi = xticks[-1][1] - 1, xticks[0][1] + 1 else: xticks = [("Bussin", 10), ("Mid Sheesh", 6), ("L", 3)] if ZOOMER else [("SSS", 10), ("S", 9), ("A", 8), ("B", 7), ("C", 6), ("D", 5), ("F", 4), ("Z", 3)] lo, hi = xticks[-1][1], xticks[0][1] fig.update_xaxes( range=[lo, hi], tickvals=[t[1] for t in xticks], ticktext=[f"{t[0]} " for t in xticks], showline=True, linewidth=1.5, linecolor=to_plotly_color(COLORS["LINE"]), mirror="allticks", ticks="inside", ticklen=5, tickwidth=1.5, tickcolor=to_plotly_color(COLORS["LINE"]), showgrid=False, zeroline=False, row=row, col=col ) fig.update_yaxes( range=[0, len(stats)], showticklabels=False, showline=True, linewidth=1.5, linecolor=to_plotly_color(COLORS["LINE"]), mirror=True, showgrid=False, zeroline=False, row=row, col=col ) for y in range(len(stats)): col_band = COLORS["BAND0"] if y % 2 == 0 else COLORS["BAND1"] y_inv = len(stats) - y - 1 fig.add_hrect(y0=y_inv, y1=y_inv+1, fillcolor=to_plotly_color(col_band), opacity=1, layer="below", line_width=0, row=row, col=col) for _, x in xticks: fig.add_vline(x=x, line_width=1.5, line_color=to_plotly_color(COLORS["LINE"]), layer="below", row=row, col=col) stats.reverse() for y, stat in enumerate(stats): if stat is None: continue name = stat['name'] for marker in stat["markers"]: if marker in MARKERS and (not CULL_SINGLETON_MARKERS or MARKERS[marker]["count"] > 1): name += MARKERS[marker]["tag"] color_box = to_plotly_color(rating_from_color_table(stat['mean_smart'], COLORS["RATINGS"])) fig.add_trace( go.Box( x=stat['ratings'], y=[y + 0.4] * len(stat['ratings']), name=name, fillcolor=color_box, line=dict(color=to_plotly_color(COLORS["MEAN"])), boxmean=True, orientation='h', showlegend=False, hoverinfo="x+name" ), row=row, col=col ) fig.add_annotation(x=3.1, y=y+0.8, text=name, showarrow=False, xanchor="left", yanchor="middle", font=dict(color=to_plotly_color(COLORS["TEXT"]), size=8), row=row, col=col) fig.add_annotation(x=9.9, y=y+0.8, text=str(stat['count']), showarrow=False, xanchor="right", yanchor="middle", font=dict(color=to_plotly_color(COLORS["STATS"]), size=8), row=row, col=col) def plot_sub_bars(fig, stats, row, col): fig.update_xaxes( showline=True, linewidth=1.5, linecolor=to_plotly_color(COLORS["LINE"]), mirror="allticks", ticks="inside", ticklen=5, tickwidth=1.5, tickcolor=to_plotly_color(COLORS["LINE"]), showgrid=False, zeroline=False, row=row, col=col ) fig.update_yaxes( range=[0, len(stats)], showticklabels=False, showline=True, linewidth=1.5, linecolor=to_plotly_color(COLORS["LINE"]), mirror=True, showgrid=False, zeroline=False, row=row, col=col ) for y in range(len(stats)): col_band = COLORS["BAND0"] if y % 2 == 0 else COLORS["BAND1"] y_inv = len(stats) - y - 1 fig.add_hrect(y0=y_inv, y1=y_inv+1, fillcolor=to_plotly_color(col_band), opacity=1, layer="below", line_width=0, row=row, col=col) stats.reverse() ys = [0.5 + y for y in range(len(stats))] buckets = [(4, "F", "#73172d"), (5, "D", "#df3e23"), (6, "C", "#f9a31b"), (7, "B", "#fffc40"), (8, "A", "#9cdb43"), (9, "S", "#20d6c7")] for i, label, color in buckets: counts = [(0 if s is None else s['buckets'][i]) for s in stats] fig.add_trace( go.Bar(y=ys, x=counts, orientation='h', name=label, marker=dict(color=color), showlegend=(row==1 and col==1)), row=row, col=col ) for y, stat in enumerate(stats): if stat is None: continue name = stat['name'] for marker in stat["markers"]: if marker in MARKERS and (not CULL_SINGLETON_MARKERS or MARKERS[marker]["count"] > 1): name += MARKERS[marker]["tag"] fig.add_annotation(x=0.0, y=y+0.85, text=name, showarrow=False, xanchor="left", yanchor="middle", font=dict(color=to_plotly_color(COLORS["TEXT"]), size=8), row=row, col=col) def plot_sub_pie(fig, stats, row, col): total_buckets = [0] * 10 for stat in stats: if stat is None: continue for i in range(len(stat['buckets'])): total_buckets[i] += stat['buckets'][i] bucket_config = [ (4, "F", "#73172d"), (5, "D", "#df3e23"), (6, "C", "#f9a31b"), (7, "B", "#fffc40"), (8, "A", "#9cdb43"), (9, "S", "#20d6c7"), ] labels, sizes, colors = [], [], [] for i, label, color in bucket_config: if total_buckets[i] > 0: labels.append(label) sizes.append(total_buckets[i]) colors.append(color) if not sizes: fig.add_annotation(x=0.5, y=0.5, text="No Data", showarrow=False, row=row, col=col) return fig.add_trace( go.Pie( labels=labels, values=sizes, marker=dict(colors=colors), textinfo='percent', textfont=dict(color='black', weight='bold'), showlegend=True ), row=row, col=col ) def create_plot( stats ): columns_data = [] column_titles = [] if SORT_BY is not None: stats = sorted(stats, key=lambda s: s[SORT_BY]) if AUX_MODE == "day": days_dict = {} for stat in stats: st = stat.get("start_time", 0) if st > 0: day_str = datetime.fromtimestamp(st).strftime('%Y-%m-%d') else: day_str = "Unknown" if day_str not in days_dict: days_dict[day_str] = [] days_dict[day_str].append(stat) sorted_days = sorted(days_dict.keys()) cols = len(sorted_days) if sorted_days else 1 items_per_col = max([len(days_dict[d]) for d in sorted_days]) if sorted_days else 1 for day in sorted_days: sub_stats = days_dict[day] if SORT_BY is not None: sub_stats = sorted(sub_stats, key=lambda s: s[SORT_BY]) sub_stats += [None] * (items_per_col - len(sub_stats)) column_titles.append(day) columns_data.append(sub_stats) else: if SORT_BY is not None: stats = sorted(stats, key=lambda s: s[SORT_BY]) cols = max(MIN_COLUMNS, min(5, round(math.sqrt(len(stats) / 6)))) if cols == 0: cols = 1 items_per_col = math.ceil(len(stats) / cols) stats += [None] * (cols * items_per_col - len(stats)) for i in range(cols): column_titles.append(" ") columns_data.append(stats[i * items_per_col : (i + 1) * items_per_col]) specs = [[{"type": "domain" if MODE == "pie" else "xy"} for _ in range(cols)]] fig = make_subplots(rows=1, cols=cols,specs=specs,horizontal_spacing=0.02) if USE_LEGEND: handles = [] if "mean" in LINES or "mean_smart" in LINES: handles.append(('Mean', COLORS["MEAN"], True)) if "median" in LINES: handles.append(('Median', COLORS["MEDIAN"], True)) if "stdev" in LINES: handles.append(('Std. Dev.', COLORS["STDEV"], True)) for marker, entry in MARKERS.items(): tag = entry["tag"] count = entry["count"] if count <= (1 if CULL_SINGLETON_MARKERS else 0) or tag == "": continue m_text = entry.get("reverse", marker) if REVERSE and entry.get("reverse") else marker m_text = entry.get("zoomer", m_text) if ZOOMER and entry.get("zoomer") else m_text title = f'({count}) {m_text}{tag}' handles.append((title, COLORS["TEXT"], False)) if handles: cols_count = 3 if len(handles) > 4 else (2 if len(handles) > 1 else 1) rows_html = [] for r in range(math.ceil(len(handles) / cols_count)): row_items = [] for c in range(cols_count): idx = r * cols_count + c if idx < len(handles): name, color, is_line = handles[idx] c_str = to_plotly_color(color) symbol = f"" if is_line else " " name_padded = name.ljust(22).replace(" ", " ") row_items.append(f"{symbol} {name_padded}") rows_html.append(" ".join(row_items)) legend_html = "
".join(rows_html) fig.add_annotation( text=legend_html, xref="paper", yref="paper", x=1.0, y=1.0, yshift=40 * UI_SCALE, xanchor="right", yanchor="bottom", align="left", font=dict(family="monospace", size=11 * UI_SCALE, color=to_plotly_color(COLORS["TEXT"])), bgcolor=to_plotly_color(COLORS["BAND0"]), bordercolor=to_plotly_color(COLORS["LINE"]), borderwidth=1.5, borderpad=8, showarrow=False ) for i, stats_sub in enumerate(columns_data): col_idx = i + 1 row_idx = 1 title_text = column_titles[i] if title_text and title_text.strip(): x_ref_str = "x domain" if col_idx == 1 else f"x{col_idx} domain" fig.add_annotation( text=title_text, xref=x_ref_str, yref="paper", x=0.5, y=1.0, yshift=20 * UI_SCALE, xanchor="center", yanchor="bottom", showarrow=False, font=dict(color=to_plotly_color(COLORS["TEXT"]), size=14 * UI_SCALE) ) if MODE == "scatter": plot_sub_scatter(fig, stats_sub, row_idx, col_idx) elif MODE == "bars": plot_sub_bars(fig, stats_sub, row_idx, col_idx) elif MODE == "boxplot": plot_sub_boxplot(fig, stats_sub, row_idx, col_idx) elif MODE == "pie": plot_sub_pie(fig, stats_sub, row_idx, col_idx) else: raise Exception(f'invalid mode: {MODE}') plot_height = (items_per_col * 50 + 200) * UI_SCALE plot_width = (cols * 400 + 100) * UI_SCALE fig.update_layout( title=dict( text=f"{TITLE}
{SUBTITLE}" if 'SUBTITLE' in globals() else TITLE, x=0.5, xanchor='center', yanchor='top', font=dict(size=24 * UI_SCALE) ), font=dict(size=12 * UI_SCALE), annotationdefaults=dict(font=dict(color=to_plotly_color(COLORS["TEXT"]), size=14 * UI_SCALE)), template="plotly_dark" if DARK else "plotly_white", plot_bgcolor=to_plotly_color(COLORS["BAND0"]), paper_bgcolor=to_plotly_color(COLORS["BAND0"]), height=plot_height, width=plot_width, margin=dict(t=160 * UI_SCALE, b=60 * UI_SCALE, l=20 * UI_SCALE, r=20 * UI_SCALE), barmode='stack' ) """ fig.add_annotation( #text=f"https://git.ecker.tech/chart/{TITLE.replace(' ', '')}", text=f"https://chartfag.neocities.org/charts/", xref="paper", yref="paper", x=0.0, y=0.0, yshift=-40, xanchor="left", yanchor="top", showarrow=False, font=dict(color=to_plotly_color(COLORS["STATS"]), size=10) ) """ return fig def compute_mean_smart(ratings): ratings = sorted( ratings ) cutoff = len(ratings) // 8 if cutoff > 0: ratings = ratings[cutoff:-cutoff] return statistics.mean(ratings) def stat_new(name, ratings, entry={}): buckets = [0] * 10 for r in ratings: r = min(9, max(4, round(r))) buckets[r] += 1 stat = {} stat['name'] = name stat['ratings'] = ratings stat['points'] = [rating_to_point(r, len(ratings)) for r in ratings] stat['buckets'] = buckets stat['count'] = len(ratings) stat['mean'] = statistics.mean(ratings) if ratings else 0.0 stat['median'] = statistics.median(ratings) if ratings else 0.0 stat['mode'] = statistics.mode(ratings) if ratings else 0.0 stat['median_grouped'] = statistics.median_grouped(round(r) for r in ratings) if ratings else 0.0 stat['stdev'] = statistics.pstdev(ratings) if ratings else 0.0 stat['mean_smart'] = compute_mean_smart(ratings) if ratings else 0.0 # attach extra data stat["markers"] = [] if "marker" in entry: stat["markers"] = [ entry["marker"] ] elif "markers" in entry: stat["markers"] = entry["markers"] # fix up previously split markers for i, marker in enumerate(stat["markers"]): if marker in ["DNF", "invalid"]: stat["markers"][i] = "DNF/invalid" """ elif marker in ["trainwreck", "cringekino"]: stat["markers"][i] = "trainwreck/cringekino" elif marker in ["ad", "nonrun"]: stat["markers"][i] = "ad/nonrun" """ if "event" in entry: stat["event"] = entry["event"] if "urls" in entry: stat["urls"] = entry["urls"] if "scores" in entry: stat["scores"] = entry["scores"] if PRINT_STATS: print(stat_to_str(stat)) return stat # read from json def read_stats(filename): stats = [] aux = { "total": [], "markers": {}, } data = json.load(open(filename, "r", encoding='utf-8')) for name, entry in data.items(): ratings = [] if "ignore" in entry and entry["ignore"]: continue op = entry["posts"][0].split("#p")[-1] if "posts" in entry and entry["posts"] else None start_time = entry["times"][op] if "times" in entry and op in entry["times"] else 0 thread_url = entry["posts"][0].split("#")[0] if "posts" in entry and entry["posts"] else "" # convert lists to dicts (for old data) if isinstance( entry["ratings"], list ): entry["ratings"] = { f'{i}': r for i, r in enumerate(entry["ratings"]) } entry["urls"] = [] entry["scores"] = [] # parse ratings in each run for no, score in entry["ratings"].items(): #cur_time = entry["times"][no] if "times" in entry and no in entry["times"] else 0 if start_time > 0 and CUTOFF_SECONDS > 0: if no not in entry["times"]: continue cur_time = entry["times"][no] if cur_time - start_time > CUTOFF_SECONDS: print("Culled", name, no, score, cur_time - start_time) continue score = score.upper() # trim extraneous characters (for example: ZZZZZZZ) if not already a valid score if score not in SCORES: # check if SSSS / ZZZZ if score[:4] in SCORES: score = score[:4] # check if first letter would match instead (per-run ratings that just share the first letter but keep it as a whole word) elif score[:1] in SCORES: score = score[:1] else: score = score[:3] # cull anything not A-F if DROP_Z_S: if SCORES[score] <= SCORES["Z"] or SCORES["S"] < SCORES[score]: continue # invalid score if score not in SCORES: continue # increment totals (I don't remember what I originally used this for) TOTAL_SCORES[score] += 1 # score modifiers rating = SCORES[score] # randomize downward #if "randomize" in entry and CUTOFF_SECONDS == 0 and False: if "randomize" in entry: lo = SCORES[list(SCORES.keys())[0]] hi = SCORES[list(SCORES.keys())[-1]] if True: #if random.random() < entry["randomize"] and rating > hi: rating = random.uniform(lo, hi) if ("reverse" in entry and random.random() < entry["reverse"]) or REVERSE: rating = 10 - rating + 2.75 ratings.append( rating ) entry["scores"].append( ( score, no ) ) entry["urls"].append( f"{thread_url}#p{no}" ) aux["total"].append( rating ) stat = stat_new(name, ratings, entry) stat["start_time"] = start_time - DATE_OFFSET if FILTER_BY and FILTER_BY not in {*stat["markers"]}: continue stats.append(stat) # flatten for marker in stat["markers"]: if marker not in MARKERS: continue MARKERS[marker]["count"] += 1 # increment marker totals for marker in stat["markers"]: for rating in ratings: if marker not in aux["markers"]: aux["markers"][marker] = [] aux["markers"][marker].append( rating ) if not stat["markers"]: marker = "none" for rating in ratings: if marker not in aux["markers"]: aux["markers"][marker] = [] aux["markers"][marker].append( rating ) # show aggregate total if AUX_MODE == "total": stats = [ stat_new("total", aux["total"]) ] # aggregate by markers elif AUX_MODE == "markers": stats = [ stat_new(name, ratings) for name, ratings in aux["markers"].items() ] return stats def main(): # yuck global REVERSE global ZOOMER global DARK global USE_LEGEND global MIN_COLUMNS global CUTOFF_SECONDS global SORT_BY global FILTER_BY global MODE global AUX_MODE global OUT_FILE global PRINT_STATS parser = argparse.ArgumentParser("Charter") # actions parser.add_argument("--fetch", action="store_true", help="Fetch ratings from queue") parser.add_argument("--plot", action="store_true", help="Plot ratings from file") parser.add_argument("--dump", action="store_true", help="Dumps rating as metrics") parser.add_argument("--upload", action="store_true", help="Uploads to neocities") # queue-less args parser.add_argument("--url", type=str, default=None, help="Post link to fetch ratings from its replies") parser.add_argument("--name", type=str, default=None, help="Name of run for rating") parser.add_argument("--markers", type=str, default=None, help="Markers to attach to run rating") # modifiers parser.add_argument("--sort-by", type=str, default=None, help="Sort plotted ratings by this value") parser.add_argument("--filter-by", type=str, default=None, help="Filters for runs with this value") parser.add_argument("--mode", type=str, default=None, help="Additional modes (scatter | bars | boxplot)") parser.add_argument("--aux-mode", type=str, default=None, help="Additional modes (total | markers)") parser.add_argument("--cutoff-seconds", type=int, default=None, help="Ignore ratings made X seconds after the rating post") parser.add_argument("--cutoff-minutes", type=int, default=None, help="Ignore ratings made X minutes after the rating post") # lesser used modifiers parser.add_argument("--reverse", action="store_true", help="Reverses the ratings") parser.add_argument("--zoomer", action="store_true", help="Zoomer friendly scales") parser.add_argument("--light", action="store_true", help="Light mode") parser.add_argument("--no-legend", action="store_true", help="Hides the legend") parser.add_argument("--no-html", action="store_true", help="Skip saving an HTML copy") parser.add_argument("--copy", action="store_true", help="Saves a copy by the timestamp") args = parser.parse_args() if args.sort_by: SORT_BY = args.sort_by if args.filter_by: FILTER_BY = args.filter_by if args.aux_mode: AUX_MODE = args.aux_mode if AUX_MODE == "total": args.no_legend = True MIN_COLUMNS = 1 modifiers = [ f'[{SORT_BY or AUX_MODE or "chronological"}]' ] if args.mode: MODE = args.mode modifiers.append(f'[type={MODE}]') if args.filter_by: modifiers.append(f'[filter={FILTER_BY}]') if args.cutoff_seconds: CUTOFF_SECONDS = args.cutoff_seconds elif args.cutoff_minutes: CUTOFF_SECONDS = args.cutoff_minutes * 60 if CUTOFF_SECONDS > 0: modifiers.append(f'[cutoff={CUTOFF_SECONDS//60}]') OUT_FILE = f'./images/ratings{"".join(modifiers)}.png' REVERSE = args.reverse ZOOMER = args.zoomer DARK = not args.light USE_LEGEND = not args.no_legend PRINT_STATS = args.dump if args.fetch: queue = [] # inject to queue if args.url and args.name: queue = [{ "post": args.url, "name": args.name, "markers": args.markers.split(",") if args.markers else None }] fetch_ratings(queue=queue) if args.plot: stats = read_stats(IN_RATINGS_FILE) fig = create_plot(stats) if not args.no_html: click_js = """ var myPlot = document.getElementsByClassName('plotly-graph-div')[0]; myPlot.on('plotly_click', function(data){ if (data.points && data.points.length > 0) { var url = data.points[0].customdata; if(url && typeof url === 'string') { window.open(url, '_blank'); } } }); myPlot.on('plotly_hover', function(data){ if (data.points && data.points.length > 0 && data.points[0].customdata) { myPlot.style.cursor = 'pointer'; } }); myPlot.on('plotly_unhover', function(data){ myPlot.style.cursor = 'default'; }); """ html_file = OUT_FILE.replace('.png', '.html') html_str = fig.to_html(full_html=True, post_script=click_js) css_injection = f"" html_str = html_str.replace("", f"\n\t{css_injection}") with open(html_file, "w", encoding="utf-8") as f: f.write(html_str) """ if args.upload: import neocities nc = neocities.NeoCities(api_key=YOUR_API_KEY) nc.upload((html_file, 'charts/index.html')) """ if args.copy: print(f'Saving chart copy: {OUT_FILE_TIMESTAMP}') fig.write_image(OUT_FILE_TIMESTAMP, scale=1.0) print(f'Saving image chart: {OUT_FILE}') fig.write_image(OUT_FILE, scale=2.0) if not args.fetch and not args.plot: raise Exception("Specify --fetch or --plot") if __name__ == "__main__": main()