implement heatmap (fixes #31), fix a typo in palette, add opacity slider to virginmap

This commit is contained in:
Grant 2024-06-05 17:00:13 -06:00
parent b38b1d8b59
commit 5a30f3bda9
17 changed files with 441 additions and 61 deletions

View File

@ -10,6 +10,7 @@ import { Template } from "./Template";
import { IRouterData, Router } from "../lib/router";
import { KeybindManager } from "../lib/keybinds";
import { VirginOverlay } from "./Overlay/VirginOverlay";
import { HeatmapOverlay } from "./Overlay/HeatmapOverlay";
export const CanvasWrapper = () => {
const { config } = useAppContext();
@ -19,6 +20,7 @@ export const CanvasWrapper = () => {
<main>
<PanZoomWrapper>
<VirginOverlay />
<HeatmapOverlay />
{config && <Template />}
<CanvasInner />
</PanZoomWrapper>

View File

@ -0,0 +1,154 @@
import { useCallback, useEffect, useRef } from "react";
import { useAppContext } from "../../contexts/AppContext";
import { KeybindManager } from "../../lib/keybinds";
import { api } from "../../lib/utils";
import { toast } from "react-toastify";
import network from "../../lib/network";
export const HeatmapOverlay = () => {
const { config, heatmapOverlay, setHeatmapOverlay } = useAppContext();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const handleKeybind = () => {
setHeatmapOverlay((v) => ({ ...v, enabled: !v.enabled }));
};
KeybindManager.on("TOGGLE_HEATMAP", handleKeybind);
return () => {
KeybindManager.off("TOGGLE_HEATMAP", handleKeybind);
};
}, [setHeatmapOverlay]);
useEffect(() => {
if (!config) {
console.warn("[HeatmapOverlay] config is not defined");
return;
}
if (!canvasRef.current) {
console.warn("[HeatmapOverlay] canvasRef is not defined");
return;
}
const [width, height] = config.canvas.size;
canvasRef.current.width = width;
canvasRef.current.height = height;
}, [config]);
const drawHeatmap = useCallback(
(rawData: string) => {
console.debug("[HeatmapOverlay] drawing heatmap");
if (!config) {
console.warn("[HeatmapOverlay] no config instance available");
return;
}
const ctx = canvasRef.current!.getContext("2d");
if (!ctx) {
console.warn("[HeatmapOverlay] canvas context cannot be aquired");
return;
}
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
if (heatmapOverlay.enabled) {
let heatmap = rawData.split("");
let lines: number[][] = [];
while (heatmap.length > 0) {
// each pixel is stored as 2 characters
let line = heatmap.splice(0, config?.canvas.size[0] * 2).join("");
let pixels = (line.match(/.{1,2}/g) || []).map(
(v) => parseInt(v, 36) / 100
);
lines.push(pixels);
}
for (let y = 0; y < lines.length; y++) {
for (let x = 0; x < lines[y].length; x++) {
const val = lines[y][x];
ctx.fillStyle = `rgba(255, 0, 0, ${Math.max(val, 0.1).toFixed(2)})`;
ctx.fillRect(x, y, 1, 1);
}
}
} else {
console.warn(
"[HeatmapOverlay] drawHeatmap called with heatmap disabled"
);
}
},
[config, heatmapOverlay.enabled]
);
const updateHeatmap = useCallback(() => {
setHeatmapOverlay((v) => ({ ...v, loading: true }));
api<{ heatmap: string }, "heatmap_not_generated">("/api/heatmap")
.then(({ status, data }) => {
if (status === 200 && data.success) {
drawHeatmap(data.heatmap);
} else {
if ("error" in data) {
switch (data.error) {
case "heatmap_not_generated":
toast.info("Heatmap is not generated. Try again shortly");
setHeatmapOverlay((v) => ({ ...v, enabled: false }));
break;
default:
toast.error("Unknown error: " + data.error);
}
} else {
toast.error("Failed to load heatmap: Error " + status);
}
}
})
.finally(() => {
setHeatmapOverlay((v) => ({ ...v, loading: false }));
});
}, [drawHeatmap, setHeatmapOverlay]);
useEffect(() => {
if (!canvasRef.current) {
console.warn("[HeatmapOverlay] canvasRef is not defined");
return;
}
updateHeatmap();
return () => {};
}, [canvasRef, heatmapOverlay.enabled, updateHeatmap]);
useEffect(() => {
if (heatmapOverlay.enabled) {
console.debug("[HeatmapOverlay] subscribing to heatmap updates");
network.subscribe("heatmap");
} else {
console.debug("[HeatmapOverlay] unsubscribing from heatmap updates");
network.unsubscribe("heatmap");
}
network.on("heatmap", drawHeatmap);
return () => {
network.off("heatmap", drawHeatmap);
};
}, [drawHeatmap, heatmapOverlay.enabled]);
return (
<canvas
id="heatmap-overlay"
className="board-overlay no-interact pixelate"
ref={(r) => (canvasRef.current = r)}
width="1000"
height="1000"
style={{
display: heatmapOverlay.enabled ? "block" : "none",
opacity: heatmapOverlay.opacity.toFixed(1),
}}
/>
);
};

View File

@ -1,8 +1,9 @@
import { Switch } from "@nextui-org/react";
import { Slider, Spinner, Switch } from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
export const OverlaySettings = () => {
const { showVirginOverlay, setShowVirginOverlay } = useAppContext();
const { virginOverlay, setVirginOverlay, heatmapOverlay, setHeatmapOverlay } =
useAppContext();
return (
<>
@ -11,11 +12,49 @@ export const OverlaySettings = () => {
</header>
<section>
<Switch
isSelected={showVirginOverlay}
onValueChange={setShowVirginOverlay}
isSelected={virginOverlay.enabled}
onValueChange={(v) =>
setVirginOverlay((vv) => ({ ...vv, enabled: v }))
}
>
Virgin Map Overlay
</Switch>
{virginOverlay.enabled && (
<Slider
label="Virgin Map Opacity"
step={0.1}
minValue={0}
maxValue={1}
value={virginOverlay.opacity}
onChange={(v) =>
setVirginOverlay((vv) => ({ ...vv, opacity: v as number }))
}
getValue={(v) => (v as number) * 100 + "%"}
/>
)}
<Switch
isSelected={heatmapOverlay.enabled}
onValueChange={(v) =>
setHeatmapOverlay((vv) => ({ ...vv, enabled: v }))
}
>
{heatmapOverlay.loading && <Spinner size="sm" />}
Heatmap Overlay
</Switch>
{heatmapOverlay.enabled && (
<Slider
label="Heatmap Opacity"
step={0.1}
minValue={0}
maxValue={1}
value={heatmapOverlay.opacity}
onChange={(v) =>
setHeatmapOverlay((vv) => ({ ...vv, opacity: v as number }))
}
getValue={(v) => (v as number) * 100 + "%"}
/>
)}
</section>
</>
);

View File

@ -4,12 +4,12 @@ import { Canvas } from "../../lib/canvas";
import { KeybindManager } from "../../lib/keybinds";
export const VirginOverlay = () => {
const { config, showVirginOverlay, setShowVirginOverlay } = useAppContext();
const { config, virginOverlay, setVirginOverlay } = useAppContext();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const handleKeybind = () => {
setShowVirginOverlay((v) => !v);
setVirginOverlay((v) => ({ ...v, enabled: !v.enabled }));
};
KeybindManager.on("TOGGLE_VIRGIN", handleKeybind);
@ -17,7 +17,7 @@ export const VirginOverlay = () => {
return () => {
KeybindManager.off("TOGGLE_VIRGIN", handleKeybind);
};
}, [setShowVirginOverlay]);
}, [setVirginOverlay]);
useEffect(() => {
if (!config) {
@ -74,7 +74,8 @@ export const VirginOverlay = () => {
width="1000"
height="1000"
style={{
display: showVirginOverlay ? "block" : "none",
display: virginOverlay.enabled ? "block" : "none",
opacity: virginOverlay.opacity.toFixed(1),
}}
/>
);

View File

@ -3,11 +3,11 @@ import { useAppContext } from "../../contexts/AppContext";
import { Canvas } from "../../lib/canvas";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { IPalleteContext } from "@sc07-canvas/lib/src/net";
import { IPaletteContext } from "@sc07-canvas/lib/src/net";
export const Palette = () => {
const { config, user } = useAppContext();
const [pallete, setPallete] = useState<IPalleteContext>({});
const [pallete, setPallete] = useState<IPaletteContext>({});
useEffect(() => {
if (!Canvas.instance) return;

View File

@ -5,17 +5,57 @@ import {
useEffect,
useState,
} from "react";
import {
AuthSession,
ClientConfig,
IAppContext,
ICanvasPosition,
IPosition,
} from "@sc07-canvas/lib/src/net";
import { AuthSession, ClientConfig, IPosition } from "@sc07-canvas/lib/src/net";
import Network from "../lib/network";
import { Spinner } from "@nextui-org/react";
import { api } from "../lib/utils";
interface IAppContext {
config?: ClientConfig;
user?: AuthSession;
canvasPosition?: ICanvasPosition;
setCanvasPosition: (v: ICanvasPosition) => void;
cursorPosition?: IPosition;
setCursorPosition: (v?: IPosition) => void;
pixels: { available: number };
undo?: { available: true; expireAt: number };
loadChat: boolean;
setLoadChat: (v: boolean) => void;
connected: boolean;
settingsSidebar: boolean;
setSettingsSidebar: (v: boolean) => void;
pixelWhois?: { x: number; y: number; surrounding: string[][] };
setPixelWhois: (v: this["pixelWhois"]) => void;
showKeybinds: boolean;
setShowKeybinds: (v: boolean) => void;
virginOverlay: IMapOverlay;
setVirginOverlay: React.Dispatch<React.SetStateAction<IMapOverlay>>;
heatmapOverlay: IMapOverlay;
setHeatmapOverlay: React.Dispatch<React.SetStateAction<IMapOverlay>>;
hasAdmin: boolean;
}
interface ICanvasPosition {
x: number;
y: number;
zoom: number;
}
interface IMapOverlay {
enabled: boolean;
/**
* opacity of the overlay
* 0.0 - 1.0
*/
opacity: number;
loading: boolean;
}
const appContext = createContext<IAppContext>({} as any);
export const useAppContext = () => useContext(appContext);
@ -42,7 +82,16 @@ export const AppContext = ({ children }: PropsWithChildren) => {
}>();
const [showKeybinds, setShowKeybinds] = useState(false);
const [showVirginOverlay, setShowVirginOverlay] = useState(false);
const [virginOverlay, setVirginOverlay] = useState<IMapOverlay>({
enabled: false,
opacity: 1,
loading: false,
});
const [heatmapOverlay, setHeatmapOverlay] = useState<IMapOverlay>({
enabled: false,
opacity: 1,
loading: false,
});
const [hasAdmin, setHasAdmin] = useState(false);
@ -142,8 +191,10 @@ export const AppContext = ({ children }: PropsWithChildren) => {
setPixelWhois,
showKeybinds,
setShowKeybinds,
showVirginOverlay,
setShowVirginOverlay,
virginOverlay,
setVirginOverlay,
heatmapOverlay,
setHeatmapOverlay,
}}
>
{!config && (

View File

@ -1,7 +1,7 @@
import EventEmitter from "eventemitter3";
import {
ClientConfig,
IPalleteContext,
IPaletteContext,
IPosition,
Pixel,
} from "@sc07-canvas/lib/src/net";
@ -219,7 +219,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
};
};
palleteCtx: IPalleteContext = {};
palleteCtx: IPaletteContext = {};
Pallete = {
getColor: (colorId: number) => {
return this.config.pallete.colors.find((c) => c.id === colorId);
@ -236,7 +236,7 @@ export class Canvas extends EventEmitter<CanvasEvents> {
},
};
updatePallete(pallete: IPalleteContext) {
updatePallete(pallete: IPaletteContext) {
this.palleteCtx = pallete;
}

View File

@ -36,6 +36,11 @@ const KEYBINDS = enforceObjectType({
key: "KeyV",
},
],
TOGGLE_HEATMAP: [
{
key: "KeyH",
},
],
});
class KeybindManager_ extends EventEmitter<{

View File

@ -6,6 +6,7 @@ import {
ClientToServerEvents,
Pixel,
ServerToClientEvents,
Subscription,
} from "@sc07-canvas/lib/src/net";
import { toast } from "react-toastify";
@ -23,6 +24,8 @@ export interface INetworkEvents {
undo: (
data: { available: false } | { available: true; expireAt: number }
) => void;
heatmap: (heatmap: string) => void;
}
type SentEventValue<K extends keyof INetworkEvents> = EventEmitter.ArgumentMap<
@ -105,6 +108,18 @@ class Network extends EventEmitter<INetworkEvents> {
this.socket.on("undo", (undo) => {
this.emit("undo", undo);
});
this.socket.on("heatmap", (heatmap) => {
this.emit("heatmap", heatmap);
});
}
subscribe(subscription: Subscription) {
this.socket.emit("subscribe", subscription);
}
unsubscribe(subscription: Subscription) {
this.socket.emit("unsubscribe", subscription);
}
/**

View File

@ -1,5 +1,7 @@
// socket.io
export type Subscription = "heatmap";
export interface ServerToClientEvents {
canvas: (pixels: string[]) => void;
user: (user: AuthSession) => void;
@ -11,6 +13,15 @@ export interface ServerToClientEvents {
undo: (
data: { available: false } | { available: true; expireAt: number }
) => void;
/* --- subscribe events --- */
/**
* Emitted to room `sub:heatmap`
* @param heatmap
* @returns
*/
heatmap: (heatmap: string) => void;
}
export interface ClientToServerEvents {
@ -28,45 +39,9 @@ export interface ClientToServerEvents {
) => void
) => void;
undo: (ack: (_: PacketAck<{}, "no_user" | "unavailable">) => void) => void;
}
// app context
// TODO: move to client/{...}/AppContext.tsx
export interface IAppContext {
config?: ClientConfig;
user?: AuthSession;
canvasPosition?: ICanvasPosition;
setCanvasPosition: (v: ICanvasPosition) => void;
cursorPosition?: IPosition;
setCursorPosition: (v?: IPosition) => void;
pixels: { available: number };
undo?: { available: true; expireAt: number };
loadChat: boolean;
setLoadChat: (v: boolean) => void;
connected: boolean;
settingsSidebar: boolean;
setSettingsSidebar: (v: boolean) => void;
pixelWhois?: { x: number; y: number; surrounding: string[][] };
setPixelWhois: (v: this["pixelWhois"]) => void;
showKeybinds: boolean;
setShowKeybinds: (v: boolean) => void;
showVirginOverlay: boolean;
setShowVirginOverlay: React.Dispatch<React.SetStateAction<boolean>>;
hasAdmin: boolean;
}
export interface IPalleteContext {
color?: number;
}
export interface ICanvasPosition {
x: number;
y: number;
zoom: number;
subscribe: (topic: Subscription) => void;
unsubscribe: (topic: Subscription) => void;
}
export interface IPosition {
@ -74,6 +49,10 @@ export interface IPosition {
y: number;
}
export interface IPaletteContext {
color?: number;
}
// other
export type Pixel = {

View File

@ -1,6 +1,7 @@
import { Router } from "express";
import { User } from "../models/User";
import Canvas from "../lib/Canvas";
import { Logger } from "../lib/Logger";
const app = Router();
@ -70,4 +71,15 @@ app.post("/canvas/size", async (req, res) => {
res.send({ success: true });
});
app.put("/canvas/heatmap", async (req, res) => {
try {
await Canvas.generateHeatmap();
res.send({ success: true });
} catch (e) {
Logger.error(e);
res.send({ success: false, error: "Failed to generate" });
}
});
export default app;

View File

@ -234,4 +234,14 @@ app.get("/canvas/pixel/:x/:y", async (req, res) => {
});
});
app.get("/heatmap", async (req, res) => {
const heatmap = await Canvas.getCachedHeatmap();
if (!heatmap) {
return res.json({ success: false, error: "heatmap_not_generated" });
}
res.json({ success: true, heatmap });
});
export default app;

View File

@ -6,6 +6,7 @@ import { ExpressServer } from "./lib/Express";
import { SocketServer } from "./lib/SocketServer";
import { OpenID } from "./lib/oidc";
import { loadSettings } from "./lib/Settings";
import { Jobs } from "./lib/Jobs";
// Validate environment variables
@ -68,6 +69,7 @@ Promise.all([
]).then(() => {
Logger.info("Startup tasks have completed, starting server");
new Jobs();
const express = new ExpressServer();
new SocketServer(express.httpServer);
});

View File

@ -213,6 +213,82 @@ class Canvas {
color: paletteColorID,
});
}
/**
* Generate heatmap of active pixels
*
* @note expensive operation, takes a bit to execute
* @returns 2 character strings with 0-100 in radix 36 (depends on canvas size)
*/
async generateHeatmap() {
const redis = await Redis.getClient();
const now = Date.now();
const minimumDate = new Date();
minimumDate.setHours(minimumDate.getHours() - 3); // 3 hours ago
const pad = (str: string) => (str.length < 2 ? "0" : "") + str;
const heatmap: string[] = [];
for (let y = 0; y < this.canvasSize[1]; y++) {
const arr: number[] = [];
for (let x = 0; x < this.canvasSize[0]; x++) {
const pixel = (
await prisma.pixel.findMany({
where: {
x,
y,
createdAt: { gt: minimumDate },
},
orderBy: {
createdAt: "desc",
},
take: 1,
})
)?.[0];
if (pixel) {
arr.push(
((1 -
(now - pixel.createdAt.getTime()) /
(now - minimumDate.getTime())) *
100) >>
0
);
} else {
arr.push(0);
}
}
heatmap.push(arr.map((num) => pad(num.toString(36))).join(""));
}
const heatmapStr = heatmap.join("");
// cache for 5 minutes
await redis.setEx(Redis.key("heatmap"), 60 * 5, heatmapStr);
// notify anyone interested about the new heatmap
SocketServer.instance.io.to("sub:heatmap").emit("heatmap", heatmapStr);
return heatmapStr;
}
/**
* Get cache heatmap safely
* @returns see Canvas#generateHeatmap
*/
async getCachedHeatmap(): Promise<string | undefined> {
const redis = await Redis.getClient();
if (!(await redis.exists(Redis.key("heatmap")))) {
Logger.warn("Canvas#getCachedHeatmap has no cached heatmap");
return undefined;
}
return (await redis.get(Redis.key("heatmap"))) as string;
}
}
export default new Canvas();

View File

@ -0,0 +1,24 @@
import Canvas from "./Canvas";
import { Logger } from "./Logger";
export class Jobs {
constructor() {
// every 5 minutes
setInterval(this.generateHeatmap, 1000 * 60 * 5);
this.generateHeatmap();
}
async generateHeatmap() {
Logger.info("Generating heatmap...");
const now = Date.now();
await Canvas.generateHeatmap();
Logger.info(
"Generated heatmap in " +
((Date.now() - now) / 1000).toFixed(1) +
" seconds"
);
}
}

View File

@ -304,6 +304,14 @@ export class SocketServer {
ack({ success: true, data: {} });
});
socket.on("subscribe", (topic) => {
socket.join("sub:" + topic);
});
socket.on("unsubscribe", (topic) => {
socket.leave("sub:" + topic);
});
}
/**

View File

@ -9,6 +9,7 @@ interface IRedisKeys {
// canvas
pixelColor(x: number, y: number): string;
canvas(): string;
heatmap(): string;
// users
socketToSub(socketId: string): string;
@ -20,6 +21,7 @@ interface IRedisKeys {
const RedisKeys: IRedisKeys = {
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
canvas: () => `CANVAS:PIXELS`,
heatmap: () => `CANVAS:HEATMAP`,
socketToSub: (socketId: string) => `CANVAS:SOCKET:${socketId}`,
};