From 4f2cf08b27714e62c84c1eeb9c1eb38e02d5d476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Sun, 25 Oct 2026 10:29:20 +0100 Subject: [PATCH] feat: heatmap time decay also in addition of placements --- polylan_submitter/place/api.py | 12 ++- polylan_submitter/place/heatmap.py | 80 ++++++++++++++----- .../src/api/@tanstack/vue-query.gen.ts | 6 +- polylan_submitter/src/api/sdk.gen.ts | 6 +- polylan_submitter/src/api/types.gen.ts | 4 + 5 files changed, 81 insertions(+), 27 deletions(-) diff --git a/polylan_submitter/place/api.py b/polylan_submitter/place/api.py index 0f645e8..fcdc38c 100644 --- a/polylan_submitter/place/api.py +++ b/polylan_submitter/place/api.py @@ -12,6 +12,7 @@ from games.decorators import require_game_enabled from .canvas import read_canvas from .gif import build_evolution_gif from .heatmap import ( + DecayMode, build_heatmap_decay_gif, build_heatmap_evolution_gif, build_heatmap_png, @@ -85,17 +86,20 @@ def heatmap_decay_gif( request: HttpRequest, event_path: str, players: str = "", - half_life: int | None = None, + half_life: float | None = None, + mode: DecayMode = "placements", ): """Admin-only: the heatmap over time, but heat decays so pixels nobody touches anymore fade back to white. `players` is an optional comma-separated list of usernames to only - count those players' placements (combined). `half_life` is in - placements, defaults to 10 frames' worth.""" + count those players' placements (combined). `mode` is "placements" + (decay per placement, `half_life` in placements, defaults to 10 frames' + worth) or "time" (decay over time, `half_life` in minutes, defaults + to 60).""" usernames = [name.strip() for name in players.split(",") if name.strip()] return HttpResponse( - build_heatmap_decay_gif(request.event, usernames, half_life), + build_heatmap_decay_gif(request.event, usernames, half_life, mode), content_type="image/gif", ) diff --git a/polylan_submitter/place/heatmap.py b/polylan_submitter/place/heatmap.py index cec6e01..ef6a061 100644 --- a/polylan_submitter/place/heatmap.py +++ b/polylan_submitter/place/heatmap.py @@ -10,6 +10,7 @@ where the heat decays so only recent activity stays red. import math from io import BytesIO +from typing import Literal from django.db.models import Count from PIL import Image @@ -98,44 +99,74 @@ def build_heatmap_evolution_gif( return _save_gif(frames) +DecayMode = Literal["placements", "time"] + +DEFAULT_HALF_LIFE_MINUTES = 60 + + def build_heatmap_decay_gif( - event: Event, usernames: list[str] | None = None, half_life: int | None = None + event: Event, + usernames: list[str] | None = None, + half_life: float | None = None, + mode: DecayMode = "placements", ) -> bytes: """Like build_heatmap_evolution_gif, but every pixel's heat decays exponentially, so a pixel nobody touches anymore fades back to white and the GIF shows where the activity is *right now*. - `half_life` is in placements (after that many more placements anywhere - on the board, a pixel's heat is halved). Defaults to 10 frames' worth. + `mode` picks what drives the decay (and the frame spacing): + - "placements": `half_life` is in placements (after that many more + placements anywhere on the board, a pixel's heat is halved), defaults + to 10 frames' worth. Frames are one every N placements, so quiet + periods take almost no frames and nothing fades during them. + - "time": `half_life` is in minutes, defaults to an hour. Frames are + evenly spaced in time between the first and last placement, so quiet + periods actually show the board fading out. Every frame shares the same color scale (the hottest pixel across the whole replay), so a quiet period actually looks quiet.""" place_settings = PlaceSettings.current(event) width, height = place_settings.width, place_settings.height - placements = [ - (x, y) - for x, y in _placements(event, usernames) + rows = [ + (placed_at, x, y) + for placed_at, x, y in _placements(event, usernames) .order_by("placed_at") - .values_list("x", "y") + .values_list("placed_at", "x", "y") if x < width and y < height ] - step = max(1, len(placements) // MAX_FRAMES) - decay = 0.5 ** (1 / max(1, half_life or step * 10)) + # Both modes boil down to a "clock" per placement, the clock values to + # snapshot a frame at, and a half-life in that clock's unit. + if mode == "time": + clocks = [placed_at.timestamp() for placed_at, _, _ in rows] + frame_clocks = _evenly_spaced(clocks, MAX_FRAMES) + half_life_clock = (half_life or DEFAULT_HALF_LIFE_MINUTES) * 60 + else: + clocks = list(range(len(rows))) + step = max(1, len(rows) // MAX_FRAMES) + frame_clocks = [i for i in clocks if i % step == 0 or i == len(rows) - 1] + half_life_clock = half_life or step * 10 + decay = 0.5 ** (1 / max(1e-9, half_life_clock)) - # Decay is applied lazily: each pixel stores its heat as of the - # placement index it was last touched at, so a placement is O(1) - # instead of decaying the whole grid every time. + # Decay is applied lazily: each pixel stores its heat as of the clock it + # was last touched at, so a placement is O(1) instead of decaying the + # whole grid every time. heat = [0.0] * (width * height) - touched_at = [0] * (width * height) + touched_at = [0.0] * (width * height) snapshots: list[list[float]] = [] - for i, (x, y) in enumerate(placements): - index = y * width + x - heat[index] = heat[index] * decay ** (i - touched_at[index]) + 1 - touched_at[index] = i - if i % step == 0 or i == len(placements) - 1: - snapshots.append([h * decay ** (i - t) for h, t in zip(heat, touched_at)]) + placement_index = 0 + for frame_clock in frame_clocks: + while placement_index < len(rows) and clocks[placement_index] <= frame_clock: + _, x, y = rows[placement_index] + clock = clocks[placement_index] + index = y * width + x + heat[index] = heat[index] * decay ** (clock - touched_at[index]) + 1 + touched_at[index] = clock + placement_index += 1 + snapshots.append( + [h * decay ** (frame_clock - t) for h, t in zip(heat, touched_at)] + ) if not snapshots: snapshots.append(heat) @@ -151,6 +182,17 @@ def build_heatmap_decay_gif( return _save_gif(frames) +def _evenly_spaced(clocks: list[float], count: int) -> list[float]: + """`count` clock values evenly spread from the first to the last one + (just the one if they're all the same, none if `clocks` is empty).""" + if not clocks: + return [] + first, last = clocks[0], clocks[-1] + if first == last: + return [first] + return [first + (last - first) * i / (count - 1) for i in range(count)] + + def _save_gif(frames: list[Image.Image]) -> bytes: buffer = BytesIO() durations = [FRAME_DURATION_MS] * len(frames) diff --git a/polylan_submitter/src/api/@tanstack/vue-query.gen.ts b/polylan_submitter/src/api/@tanstack/vue-query.gen.ts index 16e9f93..f0e50be 100644 --- a/polylan_submitter/src/api/@tanstack/vue-query.gen.ts +++ b/polylan_submitter/src/api/@tanstack/vue-query.gen.ts @@ -969,8 +969,10 @@ export const placeApiHeatmapDecayGifQueryKey = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { diff --git a/polylan_submitter/src/api/sdk.gen.ts b/polylan_submitter/src/api/sdk.gen.ts index 4184021..e426e2b 100644 --- a/polylan_submitter/src/api/sdk.gen.ts +++ b/polylan_submitter/src/api/sdk.gen.ts @@ -399,8 +399,10 @@ export const placeApiHeatmapEvolutionGif = (options: Options) => (options.client ?? client).get({ security: [{ diff --git a/polylan_submitter/src/api/types.gen.ts b/polylan_submitter/src/api/types.gen.ts index 90e67d0..f858519 100644 --- a/polylan_submitter/src/api/types.gen.ts +++ b/polylan_submitter/src/api/types.gen.ts @@ -2808,6 +2808,10 @@ export type PlaceApiHeatmapDecayGifData = { * Half Life */ half_life?: number | null; + /** + * Mode + */ + mode?: 'placements' | 'time'; }; url: '/api/events/{event_path}/place/heatmap-decay.gif'; };