Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/scouting/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/scouting/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { ScoutedMatches } from "./scouter/pages/ScoutedMatches";
const App: FC = () => {
return (
<Routes>
<Route path="*" element={<ScoutedMatches />} />
<Route path="/scout" element={<ScoutMatch />} />
<Route path="*" element={<ScoutedMatches />} />
</Routes>
);
};
Expand Down
43 changes: 43 additions & 0 deletions apps/scouting/frontend/src/strategy/components/HeatMap.tsx
Original file line number Diff line number Diff line change
@@ -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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure this belongs in the heatmap, if it does, name it appropriately (backgroundImagePath for example)

aspectRatio: number;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider adding comment of aspectRatio = width/height or vice versa, just actually write down which one you're using

}

export const HeatMap: FC<HeatMapProps> = ({ positions, path, aspectRatio }) => {
const { heatmapLayerRef, imgRef, fallbackPoints, handleImageLoad, radius, overlaySize } = useHeatMap(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if these are all important props, just use heatmap as object, don't split them unnecessarily it's cluttered and it hurts to read

positions,
path,
aspectRatio,
);

return (
<div className="relative h-screen w-screen">
<img
ref={imgRef}
src={path}
alt="Field Map"
onLoad={handleImageLoad}
className="absolute inset-0 h-full w-full object-contain z-0"
style={{ aspectRatio }}
/>

<div
ref={heatmapLayerRef}
className="absolute inset-0 z-[1] h-full w-full pointer-events-none"
/>
<HeatMapOverlay
points={fallbackPoints}
radius={radius}
width={overlaySize.width}
height={overlaySize.height}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// בס"ד
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this entire file works, but it is not legible, it is not documented, and nobody in the team can concisely explain what it does. i'm passing it because it works, but you need to clean this up

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<HeatMapIntensityCanvasProps> = ({
points,
radius,
width,
height,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(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 (
<canvas ref={canvasRef} className="block h-full w-full" />
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// בס"ד
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

despite active protesting from the affected programmer - this file is also incomprehensible
fix it, but not now

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 => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

homework: answer what the hell does value mean

const clamped = Math.min(NORMALIZED_MAX, Math.max(NORMALIZED_MIN, value));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assuming value is not between 0 to 1 (why) - inverse lerp it so it sits between the two (and clamp)
example - normalized = clamp01((n - min) / (max - min))

const ramp = COLOR_RAMP.slice(COLOR_RAMP_OFFSET, -RAMP_LAST_OFFSET);
const rampPairs = ramp.slice(COLOR_RAMP_OFFSET, -RAMP_INDEX_STEP);
Comment on lines +44 to +45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image


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);
});
};
16 changes: 16 additions & 0 deletions apps/scouting/frontend/src/strategy/components/HeatMapOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<HeatMapOverlayProps> = ({ points, radius, width, height }) => (
<div className="absolute inset-0 z-[3] pointer-events-none opacity-80">
<HeatMapIntensityCanvas points={points} radius={radius} width={width} height={height} />
</div>
);
79 changes: 79 additions & 0 deletions apps/scouting/frontend/src/strategy/components/HeatMapUtils.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
Loading
Loading