diff --git a/apps/scouting/frontend/package.json b/apps/scouting/frontend/package.json
index 5f8c6ee..b8714d0 100644
--- a/apps/scouting/frontend/package.json
+++ b/apps/scouting/frontend/package.json
@@ -15,12 +15,14 @@
"license": "ISC",
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
+ "heatmap.js": "^2.0.5",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
+ "@types/heatmap.js": "^2.0.41",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
diff --git a/apps/scouting/frontend/src/App.tsx b/apps/scouting/frontend/src/App.tsx
index 5be893b..df0979d 100644
--- a/apps/scouting/frontend/src/App.tsx
+++ b/apps/scouting/frontend/src/App.tsx
@@ -7,8 +7,8 @@ import { ScoutedMatches } from "./scouter/pages/ScoutedMatches";
const App: FC = () => {
return (
- } />
} />
+ } />
);
};
diff --git a/apps/scouting/frontend/src/strategy/components/HeatMap.tsx b/apps/scouting/frontend/src/strategy/components/HeatMap.tsx
new file mode 100644
index 0000000..2141594
--- /dev/null
+++ b/apps/scouting/frontend/src/strategy/components/HeatMap.tsx
@@ -0,0 +1,43 @@
+// בס"ד
+import type { FC } from "react";
+import type { Point } from "@repo/scouting_types";
+import { HeatMapOverlay } from "./HeatMapOverlay";
+import { useHeatMap } from "./useHeatMap";
+
+interface HeatMapProps {
+ positions: Point[];
+ path: string;
+ aspectRatio: number;
+}
+
+export const HeatMap: FC = ({ positions, path, aspectRatio }) => {
+ const { heatmapLayerRef, imgRef, fallbackPoints, handleImageLoad, radius, overlaySize } = useHeatMap(
+ positions,
+ path,
+ aspectRatio,
+ );
+
+ return (
+
+

+
+
+
+
+ );
+};
diff --git a/apps/scouting/frontend/src/strategy/components/HeatMapIntensityCanvas.tsx b/apps/scouting/frontend/src/strategy/components/HeatMapIntensityCanvas.tsx
new file mode 100644
index 0000000..7c3ab53
--- /dev/null
+++ b/apps/scouting/frontend/src/strategy/components/HeatMapIntensityCanvas.tsx
@@ -0,0 +1,129 @@
+// בס"ד
+import type { FC } from "react";
+import { useEffect, useRef } from "react";
+import { colorizeHeatmapImageData } from "./HeatMapIntensityColorizer";
+import type { Point } from "@repo/scouting_types";
+import { isEmpty } from "@repo/array-functions";
+
+interface HeatMapIntensityCanvasProps {
+ points: Point[];
+ radius: number;
+ width: number;
+ height: number;
+}
+
+const boundaryBeginning = 0;
+const OFFSET_AMOUNT = 1;
+const HALF_CIRCLE = 2;
+const OVERLAY_BLUR_PX = 20;
+const INTENSITY_GAIN = 1.5;
+const SOFTEN_RADIUS_MULTIPLIER = 2;
+const MIN_RADIUS_PX = 0.8;
+const FULL_CIRCLE_PERIMETER = Math.PI * HALF_CIRCLE;
+const RADIUS_FADE_START = "#000000";
+const RADIUS_FADE_END = "#00000000";
+
+const ensureCanvasSize = (canvas: HTMLCanvasElement, width: number, height: number): void => {
+ canvas.width = width;
+ canvas.height = height;
+};
+
+const clearCanvas = (
+ context: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+): void => {
+ context.clearRect(boundaryBeginning, boundaryBeginning, width, height);
+};
+
+const createOffscreenContext = (
+ width: number,
+ height: number,
+): { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D } | null => {
+ const offscreen = document.createElement("canvas");
+ offscreen.width = width;
+ offscreen.height = height;
+ const context = offscreen.getContext("2d");
+ if (!context) {
+ return null;
+ }
+ return { canvas: offscreen, context };
+};
+
+const drawIntensityField = (
+ context: CanvasRenderingContext2D,
+ points: { x: number; y: number }[],
+ radius: number,
+): void => {
+ clearCanvas(context, context.canvas.width, context.canvas.height);
+ context.globalCompositeOperation = "lighter";
+ context.filter = `blur(${OVERLAY_BLUR_PX}px)`;
+
+ const softenedRadius = Math.max(
+ MIN_RADIUS_PX,
+ Math.round(radius * SOFTEN_RADIUS_MULTIPLIER),
+ );
+ points.forEach((point) => {
+ const gradient = context.createRadialGradient(
+ point.x,
+ point.y,
+ boundaryBeginning,
+ point.x,
+ point.y,
+ softenedRadius,
+ );
+ gradient.addColorStop(boundaryBeginning, RADIUS_FADE_START);
+ gradient.addColorStop(OFFSET_AMOUNT, RADIUS_FADE_END);
+ context.fillStyle = gradient;
+ context.beginPath();
+ context.arc(point.x, point.y, softenedRadius, boundaryBeginning, FULL_CIRCLE_PERIMETER);
+ context.fill();
+ });
+ context.filter = "none";
+};
+
+export const HeatMapIntensityCanvas: FC = ({
+ points,
+ radius,
+ width,
+ height,
+}) => {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas || width <= boundaryBeginning || height <= boundaryBeginning) {
+ return;
+ }
+ ensureCanvasSize(canvas, width, height);
+ const context = canvas.getContext("2d");
+ if (!context) {
+ return;
+ }
+
+ clearCanvas(context, canvas.width, canvas.height);
+ if (isEmpty(points)) {
+ return;
+ }
+
+ const offscreenPayload = createOffscreenContext(canvas.width, canvas.height);
+ if (!offscreenPayload) {
+ return;
+ }
+
+ const offscreenContext = offscreenPayload.context;
+ drawIntensityField(offscreenContext, points, radius);
+ const imageData = offscreenContext.getImageData(
+ boundaryBeginning,
+ boundaryBeginning,
+ offscreenPayload.canvas.width,
+ offscreenPayload.canvas.height,
+ );
+ colorizeHeatmapImageData(imageData, INTENSITY_GAIN);
+ context.putImageData(imageData, boundaryBeginning, boundaryBeginning);
+ }, [points, radius, width, height]);
+
+ return (
+
+ );
+};
diff --git a/apps/scouting/frontend/src/strategy/components/HeatMapIntensityColorizer.ts b/apps/scouting/frontend/src/strategy/components/HeatMapIntensityColorizer.ts
new file mode 100644
index 0000000..6629985
--- /dev/null
+++ b/apps/scouting/frontend/src/strategy/components/HeatMapIntensityColorizer.ts
@@ -0,0 +1,88 @@
+// בס"ד
+const NORMALIZED_MIN = 0;
+const NORMALIZED_MAX = 1;
+const COLOR_RAMP_OFFSET = 0;
+const BYTE_ZERO = 0;
+const BYTE_MAX = 255;
+const CHANNEL_STRIDE = 4;
+const CHANNEL_RED = 0;
+const CHANNEL_GREEN = 1;
+const CHANNEL_BLUE = 2;
+const CHANNEL_ALPHA = 3;
+const RAMP_INDEX_STEP = 1;
+const RAMP_LAST_OFFSET = 1;
+interface ColorStop {
+ stop: number;
+ color: ColorRGB;
+}
+
+interface ColorRGB {
+ red: number;
+ green: number;
+ blue: number;
+}
+const COLOR_RAMP: ColorStop[] = [
+ { stop: 0, color: { red: 0, green: 0, blue: 128 } },
+ { stop: 0.2, color: { red: 0, green: 64, blue: 255 } },
+ { stop: 0.4, color: { red: 0, green: 255, blue: 255 } },
+ { stop: 0.6, color: { red: 0, green: 255, blue: 0 }},
+ { stop: 0.8, color: { red: 255, green: 255, blue: 0 } },
+ { stop: 1, color: { red: 255, green: 0, blue: 0 } },
+];
+
+const lerp = (start: number, end: number, t: number): number =>
+ start + (end - start) * t;
+
+const lerpColor = (start: ColorRGB, end: ColorRGB, t: number): ColorRGB => ({
+ red: Math.round(lerp(start.red, end.red, t)),
+ green: Math.round(lerp(start.green, end.green, t)),
+ blue: Math.round(lerp(start.blue, end.blue, t)),
+});
+
+const getRampColor = (value: number): ColorRGB => {
+ const clamped = Math.min(NORMALIZED_MAX, Math.max(NORMALIZED_MIN, value));
+ const ramp = COLOR_RAMP.slice(COLOR_RAMP_OFFSET, -RAMP_LAST_OFFSET);
+ const rampPairs = ramp.slice(COLOR_RAMP_OFFSET, -RAMP_INDEX_STEP);
+
+ const fallback = ramp[ramp.length - RAMP_LAST_OFFSET].color;
+ const result = { value: fallback };
+ const found = { value: false };
+
+ rampPairs.forEach((start, index) => {
+ if (found.value) {
+ return;
+ }
+ const end = ramp[index + RAMP_INDEX_STEP];
+ if (clamped < start.stop || clamped > end.stop) {
+ return;
+ }
+ const range = end.stop - start.stop || NORMALIZED_MAX;
+ const time = (clamped - start.stop) / range;
+ result.value = lerpColor(start.color, end.color, time);
+ found.value = true;
+ });
+
+ return result.value;
+};
+
+export const colorizeHeatmapImageData = (
+ imageData: ImageData,
+ intensityGain: number,
+): void => {
+ const { data } = imageData;
+ data.forEach((notUsed, index) => {
+ if (index % CHANNEL_STRIDE !== BYTE_ZERO) {
+ return;
+ }
+ const alpha = data[index + CHANNEL_ALPHA];
+ if (alpha === BYTE_ZERO) {
+ return;
+ }
+ const intensity = Math.min(NORMALIZED_MAX, (alpha / BYTE_MAX) * intensityGain);
+ const color = getRampColor(intensity);
+ data[index + CHANNEL_RED] = color.red;
+ data[index + CHANNEL_GREEN] = color.green;
+ data[index + CHANNEL_BLUE] = color.blue;
+ data[index + CHANNEL_ALPHA] = Math.round(BYTE_MAX * intensity);
+ });
+};
diff --git a/apps/scouting/frontend/src/strategy/components/HeatMapOverlay.tsx b/apps/scouting/frontend/src/strategy/components/HeatMapOverlay.tsx
new file mode 100644
index 0000000..91f24fc
--- /dev/null
+++ b/apps/scouting/frontend/src/strategy/components/HeatMapOverlay.tsx
@@ -0,0 +1,16 @@
+// בס"ד
+import type { FC } from "react";
+import { HeatMapIntensityCanvas } from "./HeatMapIntensityCanvas";
+
+interface HeatMapOverlayProps {
+ points: { x: number; y: number }[];
+ radius: number;
+ width: number;
+ height: number;
+}
+
+export const HeatMapOverlay: FC = ({ points, radius, width, height }) => (
+
+
+
+);
diff --git a/apps/scouting/frontend/src/strategy/components/HeatMapUtils.ts b/apps/scouting/frontend/src/strategy/components/HeatMapUtils.ts
new file mode 100644
index 0000000..2ab71ce
--- /dev/null
+++ b/apps/scouting/frontend/src/strategy/components/HeatMapUtils.ts
@@ -0,0 +1,79 @@
+// בס"ד
+import type { Point } from "@repo/scouting_types";
+
+export interface HeatmapRenderer {
+ setDimensions?: (width: number,
+ height: number) => void;
+ canvas?: HTMLCanvasElement;
+}
+
+export interface HeatmapInstance {
+ setData: (payload: { min: number;
+ max: number;
+ data: HeatPoint[]
+ }) => void;
+ repaint: () => void;
+ _renderer?: HeatmapRenderer;
+}
+
+export interface HeatPoint {
+ x: number;
+ y: number;
+ value: number;
+}
+
+export const LAYOUT = {
+ centerDivisor: 2,
+ minCoordinate: 0,
+ zeroSize: 0,
+ pixelOffset: 1,
+} as const;
+
+export const HEAT_VALUES = {
+ min: 0,
+ max: 15,
+ point: 12,
+} as const;
+export const HEAT_STYLE = {
+ radius: 35,
+ maxOpacity: 0.8,
+ minOpacity: 0.1,
+ blur: 0.7,
+ gradient: {
+ 0.15: "rgba(0, 255, 255, 0.37)",
+ 0.35: "rgba(0, 255, 0, 0.6)",
+ 0.6: "rgba(255, 255, 0, 0.7)",
+ 0.8: "rgba(255, 165, 0, 0.8)",
+ 1: "rgba(255, 0, 0, 0.9)",
+ },
+} as const;
+
+export const clamp = (v: number ,min: number ,max: number):
+ number => Math.max(min, Math.min(max, v));
+
+interface MapHeatPointsParams {
+ positions: Point[];
+ scale: number;
+ offsetX: number;
+ offsetY: number;
+ maxX: number;
+ maxY: number;
+ value: number;
+}
+
+export const mapHeatPoints = ({
+ positions,
+ scale,
+ offsetX,
+ offsetY,
+ maxX,
+ maxY,
+ value,
+}:
+MapHeatPointsParams):
+HeatPoint[] =>
+ positions.map((position) => ({
+ x: clamp(Math.round(offsetX + position.x * scale), LAYOUT.minCoordinate, maxX),
+ y: clamp(Math.round(offsetY + position.y * scale), LAYOUT.minCoordinate, maxY),
+ value,
+ }));
diff --git a/apps/scouting/frontend/src/strategy/components/useHeatMap.ts b/apps/scouting/frontend/src/strategy/components/useHeatMap.ts
new file mode 100644
index 0000000..163a94a
--- /dev/null
+++ b/apps/scouting/frontend/src/strategy/components/useHeatMap.ts
@@ -0,0 +1,129 @@
+// בס"ד
+import { useRef, useEffect, useCallback, useState, type RefObject } from "react";
+import type { Point } from "@repo/scouting_types";
+import { HEAT_STYLE, HEAT_VALUES, LAYOUT, mapHeatPoints } from "./HeatMapUtils";
+
+export interface UseHeatMapResult {
+ heatmapLayerRef: RefObject;
+ imgRef: RefObject;
+ fallbackPoints: { x: number; y: number }[];
+ handleImageLoad: () => void;
+ radius: number;
+ overlaySize: { width: number; height: number };
+}
+
+export const useHeatMap = (
+ positions: Point[],
+ path: string,
+ aspectRatio: number,
+): UseHeatMapResult => {
+ const heatmapLayerRef = useRef(null);
+ const imgRef = useRef(null);
+ const resizeFrameRef = useRef(null);
+ const [fallbackPoints, setFallbackPoints] = useState<{ x: number; y: number }[]>([]);
+ const [overlaySize, setOverlaySize] = useState<{ width: number; height: number }>({
+ width: LAYOUT.zeroSize,
+ height: LAYOUT.zeroSize,
+ });
+ const READY_IMAGE_SIZE = LAYOUT.zeroSize;
+
+ const updateHeatmap = useCallback(() => {
+ const img = imgRef.current;//put the img
+ const layer = heatmapLayerRef.current;//put the layer
+ if (!img || !layer) return;//if the img or the layer is not found, return
+
+ const layerRect = layer.getBoundingClientRect();
+ const roundedWidth = Math.round(layerRect.width);
+ const roundedHeight = Math.round(layerRect.height);
+ if (roundedWidth <= LAYOUT.zeroSize || roundedHeight <= LAYOUT.zeroSize) return;//if the width or the height is less than 0, return
+ setOverlaySize((prev) =>
+ prev.width === roundedWidth && prev.height === roundedHeight//if the width and the height are the same as the rounded width and the rounded height, return the previous size
+ ? prev
+ : { width: roundedWidth, height: roundedHeight },
+ );
+
+ const nw = img.naturalWidth;
+ const nh = img.naturalHeight;
+ if (!img.complete || !nw || !nh) return;
+
+ const scale = Math.min(layerRect.width / nw, layerRect.height / nh);
+ const drawnW = nw * scale;
+ const drawnH = nh * scale;
+ const offsetX = (layerRect.width - drawnW) / LAYOUT.centerDivisor;
+ const offsetY = (layerRect.height - drawnH) / LAYOUT.centerDivisor;
+ const maxX = Math.max(LAYOUT.minCoordinate, Math.round(layerRect.width) - LAYOUT.pixelOffset);
+ const maxY = Math.max(LAYOUT.minCoordinate, Math.round(layerRect.height) - LAYOUT.pixelOffset);
+ const data = mapHeatPoints({
+ positions,
+ scale,
+ offsetX,
+ offsetY,
+ maxX,
+ maxY,
+ value: HEAT_VALUES.point,
+ });
+
+ const nextPoints = data.map((point) => ({ x: point.x, y: point.y }));
+ setFallbackPoints((prev) =>
+ prev.length === nextPoints.length &&
+ prev.every(
+ (point, index) =>
+ point.x === nextPoints[index]?.x &&
+ point.y === nextPoints[index]?.y,
+ )
+ ? prev
+ : nextPoints,
+ );
+ }, [positions]);
+
+ useEffect(() => {
+ if (imgRef.current?.complete && imgRef.current.naturalWidth > READY_IMAGE_SIZE) {
+ updateHeatmap();
+ }
+ }, [updateHeatmap, path, aspectRatio]);
+
+ useEffect(() => {
+ const layer = heatmapLayerRef.current;
+ if (!layer) {
+ return undefined;
+ }
+
+ const observer = new ResizeObserver(() => {
+ if (resizeFrameRef.current !== null) {
+ return;
+ }
+ resizeFrameRef.current = requestAnimationFrame(() => {
+ resizeFrameRef.current = null;
+ updateHeatmap();
+ });
+ });
+
+ observer.observe(layer);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [updateHeatmap]);
+
+ const handleImageLoad = useCallback(() => {
+ updateHeatmap();
+ }, [updateHeatmap]);
+
+ useEffect(
+ () => () => {
+ if (resizeFrameRef.current !== null) {
+ cancelAnimationFrame(resizeFrameRef.current);
+ }
+ },
+ [],
+ );
+
+ return {
+ heatmapLayerRef,
+ imgRef,
+ fallbackPoints,
+ handleImageLoad,
+ radius: HEAT_STYLE.radius,
+ overlaySize,
+ };
+};
diff --git a/packages/serde/serders/serde-record.ts b/packages/serde/serders/serde-record.ts
index 49bffd4..603ffa9 100644
--- a/packages/serde/serders/serde-record.ts
+++ b/packages/serde/serders/serde-record.ts
@@ -81,7 +81,7 @@ export const serdeRecord = (
export const serdeRecordFieldsBuilder = (
fieldNamesSerdes: Record>,
): FieldsRecordSerde => {
- const recordSerde = { serializer: {}, deserializer: {} };
+ const recordSerde: FieldsRecordSerde = { serializer: {}, deserializer: {} };
Object.entries(fieldNamesSerdes).forEach(([fieldName, fieldSerde]) => {
recordSerde.serializer[fieldName] = fieldSerde.serializer;
recordSerde.deserializer[fieldName] = fieldSerde.deserializer;
diff --git a/packages/serde/serders/serde-string.ts b/packages/serde/serders/serde-string.ts
index 729ff15..6ec0728 100644
--- a/packages/serde/serders/serde-string.ts
+++ b/packages/serde/serders/serde-string.ts
@@ -37,11 +37,15 @@ export const serdeEnumedString = (
serializer(serializedData: BitArray, data: Options) {
const neededBits = bitsNeeded(possibleValues.length);
- for (let i = 0; i < possibleValues.length; i++) {
- if (data == possibleValues[i]) {
- serdeUnsignedInt(neededBits).serializer(serializedData, i);
- return;
+ const hasMatch = possibleValues.some((value, index) => {
+ if (data !== value) {
+ return false;
}
+ serdeUnsignedInt(neededBits).serializer(serializedData, index);
+ return true;
+ });
+ if (hasMatch) {
+ return;
}
throw new Error(
`value ${data} is not included in possible values: ${possibleValues.toString()}`