Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | /** * useD3ForceLabels - D3 force simulation for label positioning * * This hook handles the positioning of labels for found regions using D3's * force simulation to avoid overlaps. It supports: * - Regular labels for large regions (positioned at centroid) * - Small region labels with arrows (positioned via force simulation) * - Obstacle avoidance (labels pushed away from other regions) */ import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force' import { type RefObject, useEffect, useState } from 'react' import type { MapData, MapRegion } from '../../types' import { getArrowStartPoint, getRenderedViewport } from './labelUtils' import type { ForceTuningConfig, RegionLabelPosition, SmallRegionLabelPosition } from './types' export interface GuessHistoryItem { regionId: string playerId: string correct: boolean } export interface UseD3ForceLabelsParams { /** Map data containing regions */ mapData: MapData /** Regions excluded from the game (filtered by size/continent) */ excludedRegions: MapRegion[] /** Set of excluded region IDs for quick lookup */ excludedRegionIds: Set<string> /** Array of found region IDs */ regionsFound: string[] /** History of guesses to track who found what */ guessHistory: GuessHistoryItem[] /** Current SVG viewBox string */ displayViewBox: string /** SVG container dimensions */ svgDimensions: { width: number; height: number } /** Reference to the SVG element */ svgRef: RefObject<SVGSVGElement | null> /** Reference to the container element */ containerRef: RefObject<HTMLDivElement | null> /** Force simulation tuning parameters */ forceTuning?: ForceTuningConfig } export interface UseD3ForceLabelsReturn { /** Positions for regular (large) region labels */ labelPositions: RegionLabelPosition[] /** Positions for small region labels with arrows */ smallRegionLabelPositions: SmallRegionLabelPosition[] } /** * Hook for calculating label positions using D3 force simulation * * Labels for large regions are positioned directly at the region centroid. * Labels for small regions use D3 force simulation to avoid overlaps and * are connected to their regions with arrows. */ export function useD3ForceLabels({ mapData, excludedRegions, excludedRegionIds, regionsFound, guessHistory, displayViewBox, svgDimensions, svgRef, containerRef, forceTuning = {}, }: UseD3ForceLabelsParams): UseD3ForceLabelsReturn { const [labelPositions, setLabelPositions] = useState<RegionLabelPosition[]>([]) const [smallRegionLabelPositions, setSmallRegionLabelPositions] = useState< SmallRegionLabelPosition[] >([]) // Extract force tuning parameters with defaults const { showArrows = false, centeringStrength = 2.0, collisionPadding = 5, simulationIterations = 200, useObstacles = true, obstaclePadding = 10, } = forceTuning useEffect(() => { if (!svgRef.current || !containerRef.current) return const updateLabelPositions = () => { const containerRect = containerRef.current?.getBoundingClientRect() if (!containerRect) return const positions: RegionLabelPosition[] = [] const smallPositions: SmallRegionLabelPosition[] = [] // Parse viewBox for scale calculations const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 const viewBoxWidth = viewBoxParts[2] || 1000 const viewBoxHeight = viewBoxParts[3] || 1000 const svgRect = svgRef.current?.getBoundingClientRect() if (!svgRect) return // Get the actual rendered viewport accounting for preserveAspectRatio letterboxing const viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxWidth, viewBoxHeight) const scaleX = viewport.scale const scaleY = viewport.scale // Same as scaleX due to uniform scaling // Calculate SVG offset within container (accounts for padding + letterboxing) const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY // Collect all regions with their info for force simulation interface LabelNode extends SimulationNodeDatum { id: string name: string x: number y: number targetX: number targetY: number width: number height: number isFound: boolean isSmall: boolean players?: string[] } const allLabelNodes: LabelNode[] = [] // Process both included regions and excluded regions for labeling ;[...mapData.regions, ...excludedRegions].forEach((region) => { // Calculate centroid pixel position directly from SVG coordinates // Account for SVG offset within container (padding, etc.) const centroidPixelX = (region.center[0] - viewBoxX) * scaleX + svgOffsetX const centroidPixelY = (region.center[1] - viewBoxY) * scaleY + svgOffsetY const pixelX = centroidPixelX const pixelY = centroidPixelY // Get the actual region path element to measure its TRUE screen dimensions const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) if (!regionPath) return const pathRect = regionPath.getBoundingClientRect() const pixelWidth = pathRect.width const pixelHeight = pathRect.height const pixelArea = pathRect.width * pathRect.height // Check if this is a small region using ACTUAL screen pixels const isSmall = pixelWidth < 10 || pixelHeight < 10 || pixelArea < 100 // Collect label nodes for regions that need labels // Only show arrow labels for small regions if showArrows flag is enabled // Exception: Washington DC always gets arrow label (too small on USA map) const isDC = region.id === 'dc' const isExcluded = excludedRegionIds.has(region.id) // Show label if: region is found, OR it's small and arrows enabled // Note: Excluded regions do NOT get labels - they're just grayed out const shouldShowLabel = regionsFound.includes(region.id) || (isSmall && (showArrows || isDC)) if (shouldShowLabel && !isExcluded) { const players = regionsFound.includes(region.id) ? guessHistory .filter((guess) => guess.regionId === region.id && guess.correct) .map((guess) => guess.playerId) .filter((playerId, index, self) => self.indexOf(playerId) === index) : undefined const labelWidth = region.name.length * 7 + 15 const labelHeight = isSmall ? 25 : 30 // Regular found states (non-small) get positioned exactly at centroid // Only small regions go through force simulation if (isSmall) { allLabelNodes.push({ id: region.id, name: region.name, x: pixelX, // Start directly on region - will spread out to avoid collisions y: pixelY, targetX: pixelX, // Anchor point to pull back toward targetY: pixelY, width: labelWidth, height: labelHeight, isFound: regionsFound.includes(region.id), isSmall, players, }) } else { // Add directly to positions array - no force simulation positions.push({ regionId: region.id, regionName: region.name, x: pixelX, y: pixelY, players: players || [], }) } } }) // Add region obstacles to repel labels away from the map itself interface ObstacleNode extends SimulationNodeDatum { id: string x: number y: number isObstacle: true radius: number } const obstacleNodes: ObstacleNode[] = [] // Add all regions (including unlabeled ones) as obstacles (if enabled) if (useObstacles) { mapData.regions.forEach((region) => { const ghostElement = svgRef.current?.querySelector(`[data-ghost-region="${region.id}"]`) if (!ghostElement) return const ghostRect = ghostElement.getBoundingClientRect() const pixelX = ghostRect.left - containerRect.left + ghostRect.width / 2 const pixelY = ghostRect.top - containerRect.top + ghostRect.height / 2 const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) if (!regionPath) return const pathRect = regionPath.getBoundingClientRect() const regionRadius = Math.max(pathRect.width, pathRect.height) / 2 obstacleNodes.push({ id: `obstacle-${region.id}`, isObstacle: true, x: pixelX, y: pixelY, radius: regionRadius + obstaclePadding, }) }) } // Combine labels and obstacles for simulation const allNodes = [...allLabelNodes, ...obstacleNodes] // Run force simulation to position labels without overlaps if (allLabelNodes.length > 0) { const simulation = forceSimulation(allNodes) .force( 'collide', forceCollide<LabelNode | ObstacleNode>().radius((d) => { if ('isObstacle' in d && d.isObstacle) { return (d as ObstacleNode).radius } const label = d as LabelNode return Math.max(label.width, label.height) / 2 + collisionPadding }) ) .force( 'x', forceX<LabelNode | ObstacleNode>((d) => { if ('isObstacle' in d && d.isObstacle) return d.x return (d as LabelNode).targetX }).strength(centeringStrength) ) .force( 'y', forceY<LabelNode | ObstacleNode>((d) => { if ('isObstacle' in d && d.isObstacle) return d.y return (d as LabelNode).targetY }).strength(centeringStrength) ) .stop() // Run simulation - labels start on regions and only move as needed for (let i = 0; i < simulationIterations; i++) { simulation.tick() } // Extract positions from simulation results (only small regions now) for (const node of allLabelNodes) { // Special handling for Washington DC - position off the map to avoid blocking other states if (node.id === 'dc') { // Position DC label to the right of the map, outside the main map area const containerWidth = containerRect.width const labelX = containerWidth - 80 // 80px from right edge const labelY = svgOffsetY + svgRect.height * 0.35 // Upper-middle area const arrowStart = getArrowStartPoint( labelX, labelY, node.width, node.height, node.targetX, node.targetY ) smallPositions.push({ regionId: node.id, regionName: node.name, isFound: node.isFound, labelX: labelX, labelY: labelY, lineStartX: arrowStart.x, lineStartY: arrowStart.y, lineEndX: node.targetX, lineEndY: node.targetY, }) continue // Skip normal processing } // All remaining nodes are small regions (non-small are added directly to positions) const arrowStart = getArrowStartPoint( node.x!, node.y!, node.width, node.height, node.targetX, node.targetY ) smallPositions.push({ regionId: node.id, regionName: node.name, isFound: node.isFound, labelX: node.x!, labelY: node.y!, lineStartX: arrowStart.x, lineStartY: arrowStart.y, lineEndX: node.targetX, lineEndY: node.targetY, }) } } setLabelPositions(positions) setSmallRegionLabelPositions(smallPositions) } // Small delay to ensure ghost elements are rendered const timeoutId = setTimeout(updateLabelPositions, 0) return () => { clearTimeout(timeoutId) } }, [ mapData, regionsFound, guessHistory, svgDimensions, excludedRegions, excludedRegionIds, displayViewBox, showArrows, centeringStrength, collisionPadding, simulationIterations, useObstacles, obstaclePadding, svgRef, containerRef, ]) return { labelPositions, smallRegionLabelPositions, } } |