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 ( +
+ Field Map + +
+ +
+ ); +}; 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()}`