-
Notifications
You must be signed in to change notification settings - Fork 0
heat map #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
heat map #38
Changes from all commits
26c03df
cdf8a48
3ba7000
f6feda8
b7046d5
d0cb321
ed9cfd7
a90de1f
7bf7734
fcdd32b
fca162f
cb7e015
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| aspectRatio: number; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 @@ | ||
| // בס"ד | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 @@ | ||
| // בס"ד | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. despite active protesting from the affected programmer - this file is also incomprehensible |
||
| 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 => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 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); | ||
| }); | ||
| }; | ||
| 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> | ||
| ); |
| 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, | ||
| })); |

There was a problem hiding this comment.
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)