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:
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user