vee-speedrun-ratings/chart.py

1267 lines
38 KiB
Python
Executable File

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">&gt;&gt;{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}<br>Rating: {r}<br>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"<span style='color:{c_str};'>■</span>" 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 = "<br>".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}<br><span style='font-size: 14px; color: {to_plotly_color(COLORS['STATS'])};'>{SUBTITLE}</span>" 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"<style>body {{ background-color: {COLORS['BACKGROUND']}; margin: 0; padding: 20px; text-align: center; }}</style>"
html_str = html_str.replace("<head>", f"<head>\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()