feat(know-your-world): improve mobile magnifier controls and animations

- Add animated expand/collapse transitions using react-spring for smooth magnifier resizing
- Maintain 20px margin around expanded magnifier to allow clicking outside to dismiss
- Add close button (X) to magnifier controls for dismissing the magnifier entirely
- Replace "Full Map" text button with expand/collapse icons
- Animate button visibility with opacity transitions instead of instant show/hide
- Add width/height to MagnifierSpring for animated size transitions

🤖 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-05 08:39:52 -06:00
parent 17c113e68b
commit 4449fb19b4
6 changed files with 197 additions and 63 deletions

View File

@@ -19,7 +19,9 @@ import {
} from '../features/interaction'
import { getRenderedViewport, LabelLayer, useD3ForceLabels } from '../features/labels'
import {
EXPANDED_MAGNIFIER_MARGIN,
getAdjustedMagnifiedDimensions,
getExpandedMagnifierDimensions,
getMagnifierDimensions,
type MagnifierContextValue,
MagnifierOverlayWithHandlers,
@@ -841,14 +843,36 @@ export function MapRenderer({
}
}
// Calculate leftover area (space not covered by UI) for magnifier sizing
const leftoverWidth = svgDimensions.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
const leftoverHeight = svgDimensions.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
// Calculate target magnifier dimensions based on expanded state
const { width: normalWidth, height: normalHeight } = getMagnifierDimensions(
leftoverWidth,
leftoverHeight
)
const { width: expandedWidth, height: expandedHeight } = getExpandedMagnifierDimensions(
leftoverWidth,
leftoverHeight
)
const targetWidth = isMagnifierExpanded ? expandedWidth : normalWidth
const targetHeight = isMagnifierExpanded ? expandedHeight : normalHeight
// When expanded, center the magnifier in the leftover area (with margin)
const expandedTop = SAFE_ZONE_MARGINS.top + EXPANDED_MAGNIFIER_MARGIN
const expandedLeft = SAFE_ZONE_MARGINS.left + EXPANDED_MAGNIFIER_MARGIN
// Animated spring values for smooth transitions
// Note: Zoom animation is now handled by useMagnifierZoom hook
// This spring only handles: opacity, position, and movement multiplier
// This spring handles: opacity, position, dimensions, and movement multiplier
const [magnifierSpring, magnifierApi] = useSpring(
() => ({
opacity: targetOpacity,
top: targetTop,
left: targetLeft,
top: isMagnifierExpanded ? expandedTop : targetTop,
left: isMagnifierExpanded ? expandedLeft : targetLeft,
width: targetWidth,
height: targetHeight,
movementMultiplier: getMovementMultiplier(smallestRegionSize),
config: (key) => {
if (key === 'opacity') {
@@ -861,11 +885,25 @@ export function MapRenderer({
// Lower tension = slower animation, higher friction = less overshoot
return { tension: 60, friction: 20 }
}
if (key === 'width' || key === 'height') {
// Size transitions: smooth spring for resize animation
return { tension: 200, friction: 25 }
}
// Position: medium speed
return { tension: 200, friction: 25 }
},
}),
[targetOpacity, targetTop, targetLeft, smallestRegionSize]
[
targetOpacity,
targetTop,
targetLeft,
targetWidth,
targetHeight,
smallestRegionSize,
isMagnifierExpanded,
expandedTop,
expandedLeft,
]
)
// Calculate the display viewBox using fit-crop-with-fill strategy
@@ -994,15 +1032,28 @@ export function MapRenderer({
})
// Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook
// This effect only updates the remaining spring properties: opacity, position, movement multiplier
// This effect updates the remaining spring properties: opacity, position, dimensions, movement multiplier
useEffect(() => {
magnifierApi.start({
opacity: targetOpacity,
top: targetTop,
left: targetLeft,
top: isMagnifierExpanded ? expandedTop : targetTop,
left: isMagnifierExpanded ? expandedLeft : targetLeft,
width: targetWidth,
height: targetHeight,
movementMultiplier: getMovementMultiplier(smallestRegionSize),
})
}, [targetOpacity, targetTop, targetLeft, smallestRegionSize, magnifierApi])
}, [
targetOpacity,
targetTop,
targetLeft,
targetWidth,
targetHeight,
smallestRegionSize,
magnifierApi,
isMagnifierExpanded,
expandedTop,
expandedLeft,
])
// Check if current target region needs magnification
useEffect(() => {

View File

@@ -27,6 +27,8 @@ import type { UseInteractionStateMachineReturn } from '../interaction'
export interface MagnifierSpring {
top: SpringValue<number>
left: SpringValue<number>
width: SpringValue<number>
height: SpringValue<number>
opacity: SpringValue<number>
movementMultiplier: SpringValue<number>
}

View File

@@ -3,12 +3,13 @@
*
* Mobile control buttons for the magnifier overlay:
* - Select button: Selects the region under the crosshairs
* - Full Map button: Exits expanded mode back to normal magnifier
* - Expand button: Expands magnifier to fill available space
* - Close button: Dismisses the magnifier entirely
* - Expand/Collapse button: Toggles between normal and expanded size
*/
'use client'
import { animated, useSpring } from '@react-spring/web'
import { memo, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from 'react'
// ============================================================================
@@ -28,12 +29,16 @@ export interface MagnifierControlsProps {
isDark: boolean
/** Whether pointer is locked (hides expand button when true) */
pointerLocked?: boolean
/** Whether to hide all controls except zoom label (during panning) */
hideControls?: boolean
/** Called when Select button is pressed */
onSelect: () => void
/** Called when Full Map button is pressed (exit expanded mode) */
/** Called when exiting expanded mode back to regular magnifier size */
onExitExpanded: () => void
/** Called when Expand button is pressed */
onExpand: () => void
/** Called when Close button is pressed (dismiss magnifier entirely) */
onClose: () => void
}
// ============================================================================
@@ -43,17 +48,19 @@ export interface MagnifierControlsProps {
interface ControlButtonProps {
position: 'top-right' | 'bottom-right' | 'bottom-left'
disabled?: boolean
visible?: boolean
style?: 'select' | 'secondary' | 'icon'
onClick: () => void
children: React.ReactNode
}
/**
* Base button component for magnifier controls
* Base button component for magnifier controls with animated opacity
*/
function ControlButton({
position,
disabled = false,
visible = true,
style = 'secondary',
onClick,
children,
@@ -65,14 +72,20 @@ function ControlButton({
const handleTouchEnd = (e: ReactTouchEvent) => {
e.stopPropagation()
e.preventDefault()
if (!disabled) onClick()
if (!disabled && visible) onClick()
}
const handleClick = (e: ReactMouseEvent) => {
e.stopPropagation()
if (!disabled) onClick()
if (!disabled && visible) onClick()
}
// Animate opacity based on visibility
const springProps = useSpring({
opacity: visible ? (disabled ? 0.7 : 1) : 0,
config: { tension: 300, friction: 20 },
})
// Position styles
const positionStyles: Record<string, React.CSSProperties> = {
'top-right': { top: '8px', right: '8px' },
@@ -124,9 +137,9 @@ function ControlButton({
const variantStyle = disabled ? styleVariants[style].disabled : styleVariants[style].enabled
return (
<button
<animated.button
type="button"
disabled={disabled}
disabled={disabled || !visible}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={handleClick}
@@ -145,20 +158,22 @@ function ControlButton({
color: 'white',
fontSize: '14px',
fontWeight: 'bold',
cursor: disabled ? 'not-allowed' : 'pointer',
cursor: disabled || !visible ? 'not-allowed' : 'pointer',
touchAction: 'none',
opacity: disabled ? 0.7 : 1,
pointerEvents: visible ? 'auto' : 'none',
opacity: springProps.opacity,
}}
>
{children}
</button>
</animated.button>
)
}
// ============================================================================
// Expand Icon
// Icons
// ============================================================================
/** Expand icon - arrows pointing outward (fullscreen) */
function ExpandIcon({ isDark }: { isDark: boolean }) {
return (
<svg
@@ -179,6 +194,46 @@ function ExpandIcon({ isDark }: { isDark: boolean }) {
)
}
/** Collapse icon - arrows pointing inward (exit fullscreen) */
function CollapseIcon({ isDark }: { isDark: boolean }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={isDark ? '#60a5fa' : '#3b82f6'}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="14" y1="10" x2="21" y2="3" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
)
}
/** Close icon - X mark */
function CloseIcon({ isDark }: { isDark: boolean }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={isDark ? '#f87171' : '#ef4444'}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
)
}
// ============================================================================
// Main Component
// ============================================================================
@@ -186,10 +241,14 @@ function ExpandIcon({ isDark }: { isDark: boolean }) {
/**
* Magnifier control buttons for touch devices.
*
* Layout:
* - Expand button: top-right (when not expanded)
* - Select button: bottom-right (when visible)
* - Full Map button: bottom-left (when expanded)
* Layout when NOT expanded:
* - Expand button (fullscreen icon): bottom-right
* - Select button: bottom-left (when visible)
*
* Layout when expanded:
* - Close button (X icon): top-right
* - Collapse button (exit fullscreen icon): bottom-right
* - Select button: bottom-left (when visible)
*/
export const MagnifierControls = memo(function MagnifierControls({
isTouchDevice,
@@ -198,9 +257,11 @@ export const MagnifierControls = memo(function MagnifierControls({
isSelectDisabled,
isDark,
pointerLocked = false,
hideControls = false,
onSelect,
onExitExpanded,
onExpand,
onClose,
}: MagnifierControlsProps) {
// Only render on touch devices
if (!isTouchDevice) {
@@ -209,31 +270,41 @@ export const MagnifierControls = memo(function MagnifierControls({
return (
<>
{/* Expand button - top-right corner (when not expanded and not pointer locked) */}
{!isExpanded && !pointerLocked && (
<ControlButton position="top-right" style="icon" onClick={onExpand}>
<ExpandIcon isDark={isDark} />
</ControlButton>
)}
{/* Close button (X) - top-right corner - dismisses magnifier entirely */}
<ControlButton position="top-right" style="icon" visible={!hideControls} onClick={onClose}>
<CloseIcon isDark={isDark} />
</ControlButton>
{/* Select button - bottom-right corner (when triggered by drag) */}
{showSelectButton && (
<ControlButton
position="bottom-right"
style="select"
disabled={isSelectDisabled}
onClick={onSelect}
>
Select
</ControlButton>
)}
{/* Expand button - bottom-right corner (when not expanded and not pointer locked) */}
<ControlButton
position="bottom-right"
style="icon"
visible={!hideControls && !isExpanded && !pointerLocked}
onClick={onExpand}
>
<ExpandIcon isDark={isDark} />
</ControlButton>
{/* Full Map button - bottom-left corner (when expanded) */}
{isExpanded && showSelectButton && (
<ControlButton position="bottom-left" style="secondary" onClick={onExitExpanded}>
Full Map
</ControlButton>
)}
{/* Collapse button - bottom-right corner (when expanded) - shrinks to regular size */}
<ControlButton
position="bottom-right"
style="icon"
visible={!hideControls && isExpanded}
onClick={onExitExpanded}
>
<CollapseIcon isDark={isDark} />
</ControlButton>
{/* Select button - bottom-left corner (when triggered by drag) */}
<ControlButton
position="bottom-left"
style="select"
visible={showSelectButton}
disabled={isSelectDisabled}
onClick={onSelect}
>
Select
</ControlButton>
</>
)
})

View File

@@ -123,24 +123,16 @@ export function MagnifierOverlay({
// -------------------------------------------------------------------------
// Early Returns
// -------------------------------------------------------------------------
// Calculate magnifier size based on leftover rectangle (area not covered by UI)
// Get container and SVG info for viewBox calculations
const containerRect = containerRef.current?.getBoundingClientRect()
if (!containerRect || !svgRef.current || !cursorPosition) {
return null
}
// Calculate leftover area for debug/label positioning (not for magnifier sizing)
const leftoverWidth = containerRect.width - safeZoneMargins.left - safeZoneMargins.right
const leftoverHeight = containerRect.height - safeZoneMargins.top - safeZoneMargins.bottom
// When expanded (during/after pinch-to-zoom), use full leftover area
// Otherwise use the normal calculated dimensions
const { width: normalWidth, height: normalHeight } = getMagnifierDimensions(
leftoverWidth,
leftoverHeight
)
const magnifierWidthPx = isMagnifierExpanded ? leftoverWidth : normalWidth
const magnifierHeightPx = isMagnifierExpanded ? leftoverHeight : normalHeight
const svgRect = svgRef.current.getBoundingClientRect()
const { x: viewBoxX, y: viewBoxY, width: viewBoxWidth, height: viewBoxHeight } = parsedViewBox
@@ -154,11 +146,11 @@ export function MagnifierOverlay({
onTouchCancel={handleMagnifierTouchEnd}
style={{
position: 'absolute',
// When expanded, position at top-left of leftover area; otherwise use animated positioning
top: isMagnifierExpanded ? safeZoneMargins.top : magnifierSpring.top,
left: isMagnifierExpanded ? safeZoneMargins.left : magnifierSpring.left,
width: magnifierWidthPx,
height: magnifierHeightPx,
// Position and size are always animated via react-spring
top: magnifierSpring.top,
left: magnifierSpring.left,
width: magnifierSpring.width,
height: magnifierSpring.height,
// Border color priority: 1) Hot/cold heat colors (if enabled), 2) High zoom gold, 3) Default blue
border: (() => {
// When hot/cold is enabled, use heat-based colors (from memoized magnifierBorderStyle)
@@ -538,7 +530,7 @@ export function MagnifierOverlay({
/>
)}
{/* Mobile magnifier controls (Expand, Select, Full Map buttons) */}
{/* Mobile magnifier controls (Expand, Select, Close buttons) */}
<MagnifierControls
isTouchDevice={isTouchDevice}
showSelectButton={
@@ -548,9 +540,11 @@ export function MagnifierOverlay({
isSelectDisabled={!hoveredRegion || regionsFound.includes(hoveredRegion)}
isDark={isDark}
pointerLocked={pointerLocked}
hideControls={isMagnifierDragging}
onSelect={selectRegionAtCrosshairs}
onExitExpanded={() => setIsMagnifierExpanded(false)}
onExpand={() => setIsMagnifierExpanded(true)}
onClose={() => interaction.dispatch({ type: 'MAGNIFIER_DEACTIVATED' })}
/>
</animated.div>
)

View File

@@ -151,7 +151,9 @@ export type {
export { useMagnifierZoom } from '../../hooks/useMagnifierZoom'
// Utilities (backward compatibility)
export {
EXPANDED_MAGNIFIER_MARGIN,
getAdjustedMagnifiedDimensions,
getExpandedMagnifierDimensions,
getMagnifierDimensions,
MAGNIFIER_SIZE_LARGE,
MAGNIFIER_SIZE_SMALL,

View File

@@ -9,6 +9,9 @@
export const MAGNIFIER_SIZE_SMALL = 1 / 3 // Used for the constrained dimension
export const MAGNIFIER_SIZE_LARGE = 1 / 2 // Used for the unconstrained dimension
// Margin around expanded magnifier (allows clicking outside to dismiss)
export const EXPANDED_MAGNIFIER_MARGIN = 20 // pixels
/**
* Calculate magnifier dimensions based on container aspect ratio.
* - Landscape (wider): 1/3 width, 1/2 height (more vertical space available)
@@ -22,6 +25,17 @@ export function getMagnifierDimensions(containerWidth: number, containerHeight:
}
}
/**
* Calculate expanded magnifier dimensions (fills leftover area minus margin).
* The margin allows clicking outside the magnifier to dismiss it.
*/
export function getExpandedMagnifierDimensions(leftoverWidth: number, leftoverHeight: number) {
return {
width: leftoverWidth - EXPANDED_MAGNIFIER_MARGIN * 2,
height: leftoverHeight - EXPANDED_MAGNIFIER_MARGIN * 2,
}
}
/**
* Calculate the magnified viewBox dimensions that match the magnifier container's aspect ratio.
*