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:
parent
8b3e01b127
commit
6d68c68a3f
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
@ -10,3 +10,6 @@ export type {
|
|||
CustomCursorProps,
|
||||
} from './CustomCursor'
|
||||
export { CustomCursor } from './CustomCursor'
|
||||
|
||||
export type { HeatCrosshairProps } from './HeatCrosshair'
|
||||
export { HeatCrosshair } from './HeatCrosshair'
|
||||
|
|
|
|||
Loading…
Reference in New Issue