feat: heatmap time decay also in addition of placements

This commit is contained in:
2026-10-25 10:29:20 +01:00
parent 9bb02463da
commit 4f2cf08b27
5 changed files with 81 additions and 27 deletions
+8 -4
View File
@@ -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",
)
+60 -18
View File
@@ -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):
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 ** (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)])
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)
@@ -969,8 +969,10 @@ export const placeApiHeatmapDecayGifQueryKey = (options: Options<PlaceApiHeatmap
* 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).
*/
export const placeApiHeatmapDecayGifOptions = (options: Options<PlaceApiHeatmapDecayGifData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiHeatmapDecayGifQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
+4 -2
View File
@@ -399,8 +399,10 @@ export const placeApiHeatmapEvolutionGif = <ThrowOnError extends boolean = false
* 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).
*/
export const placeApiHeatmapDecayGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapDecayGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapDecayGifResponses, unknown, ThrowOnError>({
security: [{
+4
View File
@@ -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';
};