refactor(know-your-world): Phase 2.3 - Extract HeatCrosshair component

Deduplicate compass crosshair code by creating reusable HeatCrosshair component:

- Create features/cursor/HeatCrosshair.tsx with size-configurable compass crosshair
  - Proportional sizing calculations based on size prop
  - Outer ring, 12 compass tick marks (cardinals highlighted in white)
  - Center dot, fixed north indicator (red triangle)
  - Spring-animated rotation with configurable shadow intensity

- Update CustomCursor.tsx to use HeatCrosshair component
  - Simplified from inline SVG (~65 lines) to component usage (~1 line)

- Update MapRenderer.tsx heat crosshair overlay to use HeatCrosshair
  - Replaced ~73 lines of inline SVG with 6-line component usage
  - Uses size=40 and shadowIntensity=0.6 to match original styling

- Update features/cursor/index.ts with HeatCrosshair exports

Net reduction: ~135 lines of duplicated compass SVG code

Part of MapRenderer refactoring Phase 6.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-03 17:52:05 -06:00
parent 8b3e01b127
commit 6d68c68a3f
4 changed files with 157 additions and 120 deletions

View File

@ -7,7 +7,7 @@ import { useTheme } from '@/contexts/ThemeContext'
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
import type { ContinentId } from '../continents'
import { usePulsingAnimation } from '../features/animations'
import { CustomCursor } from '../features/cursor'
import { CustomCursor, HeatCrosshair } from '../features/cursor'
import { useInteractionStateMachine } from '../features/interaction'
import { getRenderedViewport, LabelLayer, useD3ForceLabels } from '../features/labels'
import {
@ -3391,65 +3391,12 @@ export function MapRenderer({
transform: 'translate(-50%, -50%)',
}}
>
{/* Compass-style crosshair with heat effects - ring rotates, N stays fixed */}
<animated.svg
width="40"
height="40"
viewBox="0 0 40 40"
style={{
filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.6))',
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
}}
>
{/* Outer ring */}
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke={crosshairHeatStyle.color}
strokeWidth={crosshairHeatStyle.strokeWidth}
opacity={crosshairHeatStyle.opacity}
/>
{/* Compass tick marks - 12 ticks around the ring */}
{[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((angle) => {
const isCardinal = angle % 90 === 0
const rad = (angle * Math.PI) / 180
const innerR = isCardinal ? 10 : 14
const outerR = 16
return (
<line
key={angle}
x1={20 + innerR * Math.sin(rad)}
y1={20 - innerR * Math.cos(rad)}
x2={20 + outerR * Math.sin(rad)}
y2={20 - outerR * Math.cos(rad)}
stroke={isCardinal ? 'white' : crosshairHeatStyle.color}
strokeWidth={isCardinal ? 2.5 : 1}
strokeLinecap="round"
opacity={crosshairHeatStyle.opacity}
/>
)
})}
{/* Center dot */}
<circle
cx="20"
cy="20"
r="1.5"
fill={crosshairHeatStyle.color}
opacity={crosshairHeatStyle.opacity}
/>
{/* Counter-rotating group to keep N fixed pointing up */}
<animated.g
style={{
transformOrigin: '20px 20px',
transform: rotationAngle.to((a) => `rotate(${-a}deg)`),
}}
>
{/* North indicator - red triangle pointing up */}
<polygon points="20,2 17.5,7 22.5,7" fill="#ef4444" opacity={0.9} />
</animated.g>
</animated.svg>
<HeatCrosshair
size={40}
rotationAngle={rotationAngle}
heatStyle={crosshairHeatStyle}
shadowIntensity={0.6}
/>
</div>
)}

View File

@ -7,9 +7,10 @@
'use client'
import { animated, type SpringValue } from '@react-spring/web'
import type { SpringValue } from '@react-spring/web'
import { memo } from 'react'
import type { HeatCrosshairStyle } from '../../utils/heatStyles'
import { HeatCrosshair } from './HeatCrosshair'
// ============================================================================
// Types
@ -42,12 +43,6 @@ export interface CustomCursorProps {
flagEmoji?: string | null
}
// ============================================================================
// Constants
// ============================================================================
const COMPASS_ANGLES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
// ============================================================================
// Component
// ============================================================================
@ -99,59 +94,7 @@ export const CustomCursor = memo(function CustomCursor({
transition: 'transform 0.1s ease-out',
}}
>
{/* Compass-style crosshair with heat effects - ring rotates, N stays fixed */}
<animated.svg
width="32"
height="32"
viewBox="0 0 32 32"
style={{
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5)',
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
}}
>
{/* Outer ring */}
<circle
cx="16"
cy="16"
r="13"
fill="none"
stroke={heatStyle.color}
strokeWidth={heatStyle.strokeWidth}
opacity={heatStyle.opacity}
/>
{/* Compass tick marks - 12 ticks around the ring */}
{COMPASS_ANGLES.map((angle) => {
const isCardinal = angle % 90 === 0
const rad = (angle * Math.PI) / 180
const innerR = isCardinal ? 9 : 11
const outerR = 13
return (
<line
key={angle}
x1={16 + innerR * Math.sin(rad)}
y1={16 - innerR * Math.cos(rad)}
x2={16 + outerR * Math.sin(rad)}
y2={16 - outerR * Math.cos(rad)}
stroke={isCardinal ? 'white' : heatStyle.color}
strokeWidth={isCardinal ? 2 : 1}
strokeLinecap="round"
opacity={heatStyle.opacity}
/>
)
})}
{/* Center dot */}
<circle cx="16" cy="16" r="1.5" fill={heatStyle.color} opacity={heatStyle.opacity} />
{/* Counter-rotating group to keep N fixed pointing up */}
<animated.g
style={{
transformOrigin: '16px 16px',
transform: rotationAngle.to((a) => `rotate(${-a}deg)`),
}}
>
{/* North indicator - red triangle pointing up */}
<polygon points="16,1 14,5 18,5" fill="#ef4444" opacity={0.9} />
</animated.g>
</animated.svg>
<HeatCrosshair size={32} rotationAngle={rotationAngle} heatStyle={heatStyle} />
</div>
{/* Cursor region name label - shows what to find under the cursor */}

View File

@ -0,0 +1,144 @@
/**
* Heat Crosshair Component
*
* Reusable compass-style crosshair SVG with heat-based styling.
* Features rotating outer ring with fixed north indicator.
*/
'use client'
import { animated, type SpringValue } from '@react-spring/web'
import { memo } from 'react'
import type { HeatCrosshairStyle } from '../../utils/heatStyles'
// ============================================================================
// Constants
// ============================================================================
const COMPASS_ANGLES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
// ============================================================================
// Types
// ============================================================================
export interface HeatCrosshairProps {
/** Size of the crosshair in pixels */
size: number
/** Rotation angle spring value (degrees) */
rotationAngle: SpringValue<number>
/** Heat-based styling for crosshair */
heatStyle: HeatCrosshairStyle
/** Drop shadow intensity (0-1), default 0.5 */
shadowIntensity?: number
}
// ============================================================================
// Component
// ============================================================================
/**
* Compass-style crosshair SVG with heat-based styling.
*
* Renders:
* - Outer ring that rotates based on heat level
* - 12 compass tick marks (cardinal directions highlighted in white)
* - Center dot
* - North indicator (red triangle) that counter-rotates to stay pointing up
*
* All dimensions are calculated proportionally based on the `size` prop.
*
* @example
* ```tsx
* <HeatCrosshair
* size={32}
* rotationAngle={rotationAngleSpring}
* heatStyle={crosshairHeatStyle}
* />
* ```
*/
export const HeatCrosshair = memo(function HeatCrosshair({
size,
rotationAngle,
heatStyle,
shadowIntensity = 0.5,
}: HeatCrosshairProps) {
// Calculate proportional dimensions based on size
const center = size / 2
const ringRadius = size * 0.40625 // 13/32 or 16/40
const cardinalInnerR = ringRadius * 0.69 // 9/13 or 10/16
const minorInnerR = ringRadius * 0.846 // 11/13 or 14/16
const centerDotRadius = size * 0.047 // 1.5/32 or ~1.9/40
const northTriangleSize = size * 0.125 // 4/32 or 5/40
// Cardinal tick stroke width scales with size
const cardinalStrokeWidth = size > 36 ? 2.5 : 2
const shadowBlur = size > 36 ? 3 : 2
return (
<animated.svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
style={{
filter: `drop-shadow(0 1px ${shadowBlur}px rgba(0,0,0,${shadowIntensity + 0.1}))`,
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
}}
>
{/* Outer ring */}
<circle
cx={center}
cy={center}
r={ringRadius}
fill="none"
stroke={heatStyle.color}
strokeWidth={heatStyle.strokeWidth}
opacity={heatStyle.opacity}
/>
{/* Compass tick marks - 12 ticks around the ring */}
{COMPASS_ANGLES.map((angle) => {
const isCardinal = angle % 90 === 0
const rad = (angle * Math.PI) / 180
const innerR = isCardinal ? cardinalInnerR : minorInnerR
const outerR = ringRadius
return (
<line
key={angle}
x1={center + innerR * Math.sin(rad)}
y1={center - innerR * Math.cos(rad)}
x2={center + outerR * Math.sin(rad)}
y2={center - outerR * Math.cos(rad)}
stroke={isCardinal ? 'white' : heatStyle.color}
strokeWidth={isCardinal ? cardinalStrokeWidth : 1}
strokeLinecap="round"
opacity={heatStyle.opacity}
/>
)
})}
{/* Center dot */}
<circle
cx={center}
cy={center}
r={centerDotRadius}
fill={heatStyle.color}
opacity={heatStyle.opacity}
/>
{/* Counter-rotating group to keep N fixed pointing up */}
<animated.g
style={{
transformOrigin: `${center}px ${center}px`,
transform: rotationAngle.to((a) => `rotate(${-a}deg)`),
}}
>
{/* North indicator - red triangle pointing up */}
<polygon
points={`${center},${center - ringRadius - northTriangleSize * 0.25} ${center - northTriangleSize * 0.5},${center - ringRadius + northTriangleSize * 0.75} ${center + northTriangleSize * 0.5},${center - ringRadius + northTriangleSize * 0.75}`}
fill="#ef4444"
opacity={0.9}
/>
</animated.g>
</animated.svg>
)
})

View File

@ -10,3 +10,6 @@ export type {
CustomCursorProps,
} from './CustomCursor'
export { CustomCursor } from './CustomCursor'
export type { HeatCrosshairProps } from './HeatCrosshair'
export { HeatCrosshair } from './HeatCrosshair'