implement heatmap (fixes #31), fix a typo in palette, add opacity slider to virginmap
This commit is contained in:
parent
b38b1d8b59
commit
5a30f3bda9
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,11 @@ const KEYBINDS = enforceObjectType({
|
|||
key: "KeyV",
|
||||
},
|
||||
],
|
||||
TOGGLE_HEATMAP: [
|
||||
{
|
||||
key: "KeyH",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
class KeybindManager_ extends EventEmitter<{
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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}`,
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue