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 .canvas import read_canvas
from .gif import build_evolution_gif from .gif import build_evolution_gif
from .heatmap import ( from .heatmap import (
DecayMode,
build_heatmap_decay_gif, build_heatmap_decay_gif,
build_heatmap_evolution_gif, build_heatmap_evolution_gif,
build_heatmap_png, build_heatmap_png,
@@ -85,17 +86,20 @@ def heatmap_decay_gif(
request: HttpRequest, request: HttpRequest,
event_path: str, event_path: str,
players: 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 """Admin-only: the heatmap over time, but heat decays so pixels nobody
touches anymore fade back to white. touches anymore fade back to white.
`players` is an optional comma-separated list of usernames to only `players` is an optional comma-separated list of usernames to only
count those players' placements (combined). `half_life` is in count those players' placements (combined). `mode` is "placements"
placements, defaults to 10 frames' worth.""" (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()] usernames = [name.strip() for name in players.split(",") if name.strip()]
return HttpResponse( 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", content_type="image/gif",
) )
+60 -18
View File
@@ -10,6 +10,7 @@ where the heat decays so only recent activity stays red.
import math import math
from io import BytesIO from io import BytesIO
from typing import Literal
from django.db.models import Count from django.db.models import Count
from PIL import Image from PIL import Image
@@ -98,44 +99,74 @@ def build_heatmap_evolution_gif(
return _save_gif(frames) return _save_gif(frames)
DecayMode = Literal["placements", "time"]
DEFAULT_HALF_LIFE_MINUTES = 60
def build_heatmap_decay_gif( 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: ) -> bytes:
"""Like build_heatmap_evolution_gif, but every pixel's heat decays """Like build_heatmap_evolution_gif, but every pixel's heat decays
exponentially, so a pixel nobody touches anymore fades back to white exponentially, so a pixel nobody touches anymore fades back to white
and the GIF shows where the activity is *right now*. and the GIF shows where the activity is *right now*.
`half_life` is in placements (after that many more placements anywhere `mode` picks what drives the decay (and the frame spacing):
on the board, a pixel's heat is halved). Defaults to 10 frames' worth. - "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 Every frame shares the same color scale (the hottest pixel across the
whole replay), so a quiet period actually looks quiet.""" whole replay), so a quiet period actually looks quiet."""
place_settings = PlaceSettings.current(event) place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height width, height = place_settings.width, place_settings.height
placements = [ rows = [
(x, y) (placed_at, x, y)
for x, y in _placements(event, usernames) for placed_at, x, y in _placements(event, usernames)
.order_by("placed_at") .order_by("placed_at")
.values_list("x", "y") .values_list("placed_at", "x", "y")
if x < width and y < height if x < width and y < height
] ]
step = max(1, len(placements) // MAX_FRAMES) # Both modes boil down to a "clock" per placement, the clock values to
decay = 0.5 ** (1 / max(1, half_life or step * 10)) # 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 # Decay is applied lazily: each pixel stores its heat as of the clock it
# placement index it was last touched at, so a placement is O(1) # was last touched at, so a placement is O(1) instead of decaying the
# instead of decaying the whole grid every time. # whole grid every time.
heat = [0.0] * (width * height) heat = [0.0] * (width * height)
touched_at = [0] * (width * height) touched_at = [0.0] * (width * height)
snapshots: list[list[float]] = [] 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 index = y * width + x
heat[index] = heat[index] * decay ** (i - touched_at[index]) + 1 heat[index] = heat[index] * decay ** (clock - touched_at[index]) + 1
touched_at[index] = i touched_at[index] = clock
if i % step == 0 or i == len(placements) - 1: placement_index += 1
snapshots.append([h * decay ** (i - t) for h, t in zip(heat, touched_at)]) snapshots.append(
[h * decay ** (frame_clock - t) for h, t in zip(heat, touched_at)]
)
if not snapshots: if not snapshots:
snapshots.append(heat) snapshots.append(heat)
@@ -151,6 +182,17 @@ def build_heatmap_decay_gif(
return _save_gif(frames) 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: def _save_gif(frames: list[Image.Image]) -> bytes:
buffer = BytesIO() buffer = BytesIO()
durations = [FRAME_DURATION_MS] * len(frames) durations = [FRAME_DURATION_MS] * len(frames)
@@ -969,8 +969,10 @@ export const placeApiHeatmapDecayGifQueryKey = (options: Options<PlaceApiHeatmap
* touches anymore fade back to white. * touches anymore fade back to white.
* *
* `players` is an optional comma-separated list of usernames to only * `players` is an optional comma-separated list of usernames to only
* count those players' placements (combined). `half_life` is in * count those players' placements (combined). `mode` is "placements"
* placements, defaults to 10 frames' worth. * (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>>({ export const placeApiHeatmapDecayGifOptions = (options: Options<PlaceApiHeatmapDecayGifData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiHeatmapDecayGifQueryKey>>({
queryFn: async ({ queryKey, signal }) => { 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. * touches anymore fade back to white.
* *
* `players` is an optional comma-separated list of usernames to only * `players` is an optional comma-separated list of usernames to only
* count those players' placements (combined). `half_life` is in * count those players' placements (combined). `mode` is "placements"
* placements, defaults to 10 frames' worth. * (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>({ export const placeApiHeatmapDecayGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapDecayGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapDecayGifResponses, unknown, ThrowOnError>({
security: [{ security: [{
+4
View File
@@ -2808,6 +2808,10 @@ export type PlaceApiHeatmapDecayGifData = {
* Half Life * Half Life
*/ */
half_life?: number | null; half_life?: number | null;
/**
* Mode
*/
mode?: 'placements' | 'time';
}; };
url: '/api/events/{event_path}/place/heatmap-decay.gif'; url: '/api/events/{event_path}/place/heatmap-decay.gif';
}; };