From c7c4e7cef3ddb8af54826992f4a94b97b63e4698 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 27 Nov 2025 11:40:24 -0600 Subject: [PATCH] feat(know-your-world): add range thermometer for region size selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace checkbox-based region size selector with a dual-handle range slider: - Create reusable Thermometer components (RangeThermometer, SingleThermometer) - Use Radix UI Slider for accessible range selection - Add hover preview showing which regions would be added/removed on click - Smooth react-spring animations for map region color transitions - Fix slider thrashing by tracking last-sent values with ref - Handle drag state to prevent hover interference during slider manipulation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/DrillDownMapSelector.tsx | 246 ++++------ .../components/MapSelectorMap.tsx | 147 +++++- .../Thermometer/RangeThermometer.tsx | 449 ++++++++++++++++++ .../Thermometer/SingleThermometer.tsx | 124 +++++ apps/web/src/components/Thermometer/index.ts | 9 + apps/web/src/components/Thermometer/types.ts | 58 +++ 6 files changed, 873 insertions(+), 160 deletions(-) create mode 100644 apps/web/src/components/Thermometer/RangeThermometer.tsx create mode 100644 apps/web/src/components/Thermometer/SingleThermometer.tsx create mode 100644 apps/web/src/components/Thermometer/index.ts create mode 100644 apps/web/src/components/Thermometer/types.ts diff --git a/apps/web/src/arcade-games/know-your-world/components/DrillDownMapSelector.tsx b/apps/web/src/arcade-games/know-your-world/components/DrillDownMapSelector.tsx index 388abbc7..fde92544 100644 --- a/apps/web/src/arcade-games/know-your-world/components/DrillDownMapSelector.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/DrillDownMapSelector.tsx @@ -1,8 +1,12 @@ 'use client' import { useState, useCallback, useMemo, useRef, useEffect } from 'react' -import * as Checkbox from '@radix-ui/react-checkbox' import { css } from '@styled/css' +import { + RangeThermometer, + type ThermometerOption, + type RangePreviewState, +} from '@/components/Thermometer' import { useTheme } from '@/contexts/ThemeContext' import { MapSelectorMap } from './MapSelectorMap' import { @@ -27,6 +31,35 @@ import { type ContinentId, } from '../continents' +/** + * Size options for the range thermometer, ordered from largest to smallest + */ +const SIZE_OPTIONS: ThermometerOption[] = ALL_REGION_SIZES.map((size) => ({ + value: size, + label: REGION_SIZE_CONFIG[size].label, + shortLabel: REGION_SIZE_CONFIG[size].label, + emoji: REGION_SIZE_CONFIG[size].emoji, +})) + +/** + * Convert an array of sizes to min/max values for the range thermometer + */ +function sizesToRange(sizes: RegionSize[]): [RegionSize, RegionSize] { + const sorted = [...sizes].sort( + (a, b) => ALL_REGION_SIZES.indexOf(a) - ALL_REGION_SIZES.indexOf(b) + ) + return [sorted[0], sorted[sorted.length - 1]] +} + +/** + * Convert min/max range values back to an array of sizes + */ +function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] { + const minIdx = ALL_REGION_SIZES.indexOf(min) + const maxIdx = ALL_REGION_SIZES.indexOf(max) + return ALL_REGION_SIZES.slice(minIdx, maxIdx + 1) +} + /** * Selection path for drill-down navigation: * - [] = World level @@ -111,6 +144,9 @@ export function DrillDownMapSelector({ const [path, setPath] = useState(getInitialPath) const [hoveredRegion, setHoveredRegion] = useState(null) + const [sizeRangePreview, setSizeRangePreview] = useState | null>( + null + ) const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }) const containerRef = useRef(null) @@ -276,6 +312,54 @@ export function DrillDownMapSelector({ return excluded }, [currentLevel, path, selectedContinent, includeSizes]) + // Calculate preview regions based on hovering over the size thermometer + // Shows what regions would be added or removed if the user clicks + const { previewAddRegions, previewRemoveRegions } = useMemo(() => { + if (!sizeRangePreview) { + return { previewAddRegions: [], previewRemoveRegions: [] } + } + + // Determine which map we're looking at + const mapId = currentLevel === 2 && path[1] ? 'usa' : 'world' + const continentId: ContinentId | 'all' = + currentLevel >= 1 && path[0] ? path[0] : selectedContinent + + // Get current included region IDs + const currentIncluded = getFilteredMapDataBySizesSync( + mapId as 'world' | 'usa', + continentId, + includeSizes + ) + const currentIncludedIds = new Set(currentIncluded.regions.map((r) => r.id)) + + // Get preview included region IDs (if user clicked) + const previewSizes = rangeToSizes(sizeRangePreview.previewMin, sizeRangePreview.previewMax) + const previewIncluded = getFilteredMapDataBySizesSync( + mapId as 'world' | 'usa', + continentId, + previewSizes + ) + const previewIncludedIds = new Set(previewIncluded.regions.map((r) => r.id)) + + // Regions that would be ADDED (in preview but not currently included) + const addRegions: string[] = [] + for (const id of previewIncludedIds) { + if (!currentIncludedIds.has(id)) { + addRegions.push(id) + } + } + + // Regions that would be REMOVED (currently included but not in preview) + const removeRegions: string[] = [] + for (const id of currentIncludedIds) { + if (!previewIncludedIds.has(id)) { + removeRegions.push(id) + } + } + + return { previewAddRegions: addRegions, previewRemoveRegions: removeRegions } + }, [sizeRangePreview, currentLevel, path, selectedContinent, includeSizes]) + // Compute the label to display for the hovered region // Shows the next drill-down level name, not the individual region name const hoveredLabel = useMemo(() => { @@ -623,6 +707,8 @@ export function DrillDownMapSelector({ } hoverableRegions={currentLevel === 1 ? highlightedRegions : undefined} excludedRegions={excludedRegions} + previewAddRegions={previewAddRegions} + previewRemoveRegions={previewRemoveRegions} /> {/* Zoom Out Button - positioned inside map, upper right */} @@ -725,17 +811,13 @@ export function DrillDownMapSelector({ ) })()} - {/* Region Size Filters - positioned inside map, right side as column */} + {/* Region Size Range Selector - positioned inside map, right side */}
- {/* Select All button */} - - - {/* Size checkboxes in a column */} -
- {ALL_REGION_SIZES.map((size) => { - const config = REGION_SIZE_CONFIG[size] - const isChecked = includeSizes.includes(size) - const isOnlyOne = includeSizes.length === 1 && isChecked - const count = regionCountsBySize[size] || 0 - - const handleToggle = () => { - if (isOnlyOne) return - if (isChecked) { - onRegionSizesChange(includeSizes.filter((s) => s !== size)) - } else { - onRegionSizesChange([...includeSizes, size]) - } - } - - return ( - - {config.emoji} - - {config.label} - - - {count} - - - ) - })} -
+ onRegionSizesChange(rangeToSizes(min, max))} + orientation="vertical" + isDark={isDark} + counts={regionCountsBySize as Partial>} + showTotalCount + onHoverPreview={setSizeRangePreview} + />
diff --git a/apps/web/src/arcade-games/know-your-world/components/MapSelectorMap.tsx b/apps/web/src/arcade-games/know-your-world/components/MapSelectorMap.tsx index 7cbbee0e..088c68c6 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapSelectorMap.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapSelectorMap.tsx @@ -1,11 +1,74 @@ 'use client' -import { useMemo } from 'react' +import { useMemo, memo } from 'react' import { css } from '@styled/css' +import { animated, useSpring } from '@react-spring/web' import { useTheme } from '@/contexts/ThemeContext' import type { MapData } from '../types' import { getRegionColor } from '../mapColors' +/** + * Animated SVG path component for smooth color/style transitions + */ +interface AnimatedRegionProps { + id: string + path: string + fill: string + stroke: string + strokeWidth: number + opacity: number + isExcluded: boolean + isPreviewAdd: boolean + isPreviewRemove: boolean + onMouseEnter: () => void + onMouseLeave: () => void + onClick: (e: React.MouseEvent) => void +} + +const AnimatedRegion = memo(function AnimatedRegion({ + id, + path, + fill, + stroke, + strokeWidth, + opacity, + isExcluded, + isPreviewAdd, + isPreviewRemove, + onMouseEnter, + onMouseLeave, + onClick, +}: AnimatedRegionProps) { + const springProps = useSpring({ + fill, + stroke, + strokeWidth, + opacity, + config: { duration: 400 }, + }) + + return ( + + ) +}) + interface MapSelectorMapProps { /** Map data to display */ mapData: MapData @@ -43,6 +106,16 @@ interface MapSelectorMapProps { * These will be shown dimmed/grayed out. */ excludedRegions?: string[] + /** + * Regions that would be ADDED to the selection if the user clicks. + * Shown with a distinct "preview add" style (e.g., green tint). + */ + previewAddRegions?: string[] + /** + * Regions that would be REMOVED from the selection if the user clicks. + * Shown with a distinct "preview remove" style (e.g., red/orange tint). + */ + previewRemoveRegions?: string[] } export function MapSelectorMap({ @@ -57,6 +130,8 @@ export function MapSelectorMap({ selectedGroup, hoverableRegions, excludedRegions = [], + previewAddRegions = [], + previewRemoveRegions = [], }: MapSelectorMapProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' @@ -103,14 +178,36 @@ export function MapSelectorMap({ return excludedRegions.includes(regionId) } + // Check if a region would be added in a preview + const isRegionPreviewAdd = (regionId: string): boolean => { + return previewAddRegions.includes(regionId) + } + + // Check if a region would be removed in a preview + const isRegionPreviewRemove = (regionId: string): boolean => { + return previewRemoveRegions.includes(regionId) + } + // Get fill color for a region const getRegionFill = (regionId: string): string => { const isExcluded = isRegionExcluded(regionId) + const isPreviewAdd = isRegionPreviewAdd(regionId) + const isPreviewRemove = isRegionPreviewRemove(regionId) const isHovered = isRegionHighlighted(regionId) const isSelected = isRegionSelected(regionId) const hasSubMap = highlightedRegions.includes(regionId) - // Excluded regions are dimmed + // Preview states take precedence - show what would happen on click + if (isPreviewAdd) { + // Region would be ADDED - show with green/teal tint + return isDark ? '#065f46' : '#a7f3d0' + } + if (isPreviewRemove) { + // Region would be REMOVED - show with amber/orange tint + return isDark ? '#78350f' : '#fde68a' + } + + // Excluded regions are dimmed (but not if preview is showing something else) if (isExcluded) { return isDark ? '#1f2937' : '#e5e7eb' // Gray out excluded regions } @@ -140,10 +237,20 @@ export function MapSelectorMap({ // Get stroke color for a region const getRegionStroke = (regionId: string): string => { const isExcluded = isRegionExcluded(regionId) + const isPreviewAdd = isRegionPreviewAdd(regionId) + const isPreviewRemove = isRegionPreviewRemove(regionId) const isHovered = isRegionHighlighted(regionId) const isSelected = isRegionSelected(regionId) const hasSubMap = highlightedRegions.includes(regionId) + // Preview states take precedence + if (isPreviewAdd) { + return isDark ? '#10b981' : '#059669' // Green border + } + if (isPreviewRemove) { + return isDark ? '#f59e0b' : '#d97706' // Amber border + } + // Excluded regions get subtle stroke if (isExcluded) { return isDark ? '#374151' : '#d1d5db' @@ -167,16 +274,31 @@ export function MapSelectorMap({ // Get stroke width for a region const getRegionStrokeWidth = (regionId: string): number => { + const isPreviewAdd = isRegionPreviewAdd(regionId) + const isPreviewRemove = isRegionPreviewRemove(regionId) const isHovered = isRegionHighlighted(regionId) const isSelected = isRegionSelected(regionId) const hasSubMap = highlightedRegions.includes(regionId) + if (isPreviewAdd || isPreviewRemove) return 1.5 if (isHovered) return 2 if (isSelected) return 1.5 if (hasSubMap) return 1.5 return 0.5 } + // Get opacity for a region (preview regions should be fully visible) + const getRegionOpacity = (regionId: string): number => { + const isExcluded = isRegionExcluded(regionId) + const isPreviewAdd = isRegionPreviewAdd(regionId) + const isPreviewRemove = isRegionPreviewRemove(regionId) + + // Preview states override excluded opacity + if (isPreviewAdd || isPreviewRemove) return 1 + if (isExcluded) return 0.5 + return 1 + } + return (
- {/* Render each region */} + {/* Render each region with smooth animations */} {displayRegions.map((region) => { const isExcluded = excludedRegions.includes(region.id) + const isPreviewAdd = previewAddRegions.includes(region.id) + const isPreviewRemove = previewRemoveRegions.includes(region.id) return ( - onRegionHover(region.id)} onMouseLeave={() => onRegionHover(null)} onClick={(e) => { e.stopPropagation() onRegionClick(region.id) }} - style={{ - cursor: 'pointer', - transition: 'all 0.15s ease', - pointerEvents: 'all', - opacity: isExcluded ? 0.5 : 1, - }} /> ) })} diff --git a/apps/web/src/components/Thermometer/RangeThermometer.tsx b/apps/web/src/components/Thermometer/RangeThermometer.tsx new file mode 100644 index 00000000..dcf16bf1 --- /dev/null +++ b/apps/web/src/components/Thermometer/RangeThermometer.tsx @@ -0,0 +1,449 @@ +'use client' + +import { css } from '@styled/css' +import * as Slider from '@radix-ui/react-slider' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { RangeThermometerProps } from './types' + +/** + * A range thermometer component that displays discrete options with two handles + * for selecting a min/max range. Uses Radix Slider for accessibility and interaction. + */ +export function RangeThermometer({ + options, + minValue, + maxValue, + onChange, + orientation = 'vertical', + isDark = false, + label, + description, + counts, + showTotalCount = true, + onHoverPreview, +}: RangeThermometerProps) { + const isVertical = orientation === 'vertical' + const [isDragging, setIsDragging] = useState(false) + + // Track last sent values to prevent duplicate onChange calls during async updates + const lastSentRef = useRef<[number, number] | null>(null) + + // Sync ref when props change from outside (e.g., parent resets values) + useEffect(() => { + const currentMinIndex = options.findIndex((opt) => opt.value === minValue) + const currentMaxIndex = options.findIndex((opt) => opt.value === maxValue) + // Only sync if we're not dragging (to avoid overwriting during drag) + if (!isDragging) { + lastSentRef.current = [currentMinIndex, currentMaxIndex] + } + }, [minValue, maxValue, options, isDragging]) + + // Convert values to indices - memoize to prevent recalculation + const minIndex = useMemo( + () => options.findIndex((opt) => opt.value === minValue), + [options, minValue] + ) + const maxIndex = useMemo( + () => options.findIndex((opt) => opt.value === maxValue), + [options, maxValue] + ) + + // Handle slider value changes - memoized to prevent Slider re-renders + // Only call onChange when values actually change to prevent server thrashing + const handleValueChange = useCallback( + (values: number[]) => { + const [newMinIndex, newMaxIndex] = values + const last = lastSentRef.current + + // Only fire if values differ from what we last sent (not props, which may be stale) + if (!last || newMinIndex !== last[0] || newMaxIndex !== last[1]) { + lastSentRef.current = [newMinIndex, newMaxIndex] + onChange(options[newMinIndex].value, options[newMaxIndex].value) + } + }, + [onChange, options] + ) + + // Calculate what the range would be if clicking on a specific option + // (moves nearest handle to that position) + const getPreviewRange = useCallback( + (hoveredIndex: number): { previewMin: T; previewMax: T } => { + const distToMin = Math.abs(hoveredIndex - minIndex) + const distToMax = Math.abs(hoveredIndex - maxIndex) + if (distToMin <= distToMax) { + // Would move min handle + return { + previewMin: options[hoveredIndex].value, + previewMax: options[maxIndex].value, + } + } else { + // Would move max handle + return { + previewMin: options[minIndex].value, + previewMax: options[hoveredIndex].value, + } + } + }, + [options, minIndex, maxIndex] + ) + + // Handle hover enter on an option - memoized + // Skip hover events while dragging to prevent interference + const handleOptionHover = useCallback( + (index: number) => { + if (onHoverPreview && !isDragging) { + onHoverPreview(getPreviewRange(index)) + } + }, + [onHoverPreview, getPreviewRange, isDragging] + ) + + // Handle hover leave - memoized + // Skip hover events while dragging to prevent interference + const handleOptionLeave = useCallback(() => { + if (onHoverPreview && !isDragging) { + onHoverPreview(null) + } + }, [onHoverPreview, isDragging]) + + // Handle drag start/end for the slider + const handlePointerDown = useCallback(() => { + setIsDragging(true) + // Clear any existing preview when starting to drag + if (onHoverPreview) { + onHoverPreview(null) + } + }, [onHoverPreview]) + + const handlePointerUp = useCallback(() => { + setIsDragging(false) + }, []) + + // Listen for global pointer up to handle drag release anywhere on screen + useEffect(() => { + if (isDragging) { + const handleGlobalPointerUp = () => { + setIsDragging(false) + } + window.addEventListener('pointerup', handleGlobalPointerUp) + window.addEventListener('pointercancel', handleGlobalPointerUp) + return () => { + window.removeEventListener('pointerup', handleGlobalPointerUp) + window.removeEventListener('pointercancel', handleGlobalPointerUp) + } + } + }, [isDragging]) + + // Calculate total count for selected range + const totalCount = useMemo(() => { + if (!counts || !showTotalCount) return null + let total = 0 + for (let i = minIndex; i <= maxIndex; i++) { + const opt = options[i] + total += counts[opt.value] || 0 + } + return total + }, [counts, showTotalCount, options, minIndex, maxIndex]) + + // Check if an option is within the selected range + const isInRange = (index: number) => index >= minIndex && index <= maxIndex + + return ( +
+ {/* Label and description */} + {(label || description) && ( +
+ {label && ( +
+ {label} +
+ )} + {description && ( +
+ {description} +
+ )} +
+ )} + + {/* Main container with labels and slider */} +
+ {/* Labels column */} +
+ {options.map((option, index) => { + const inRange = isInRange(index) + const count = counts?.[option.value] + + return ( + + ) + })} +
+ + {/* Slider track */} +
+ + + + + + {/* Min thumb */} + + + {/* Max thumb */} + + +
+
+ + {/* Total count display */} + {totalCount !== null && ( +
+ + {totalCount} + + + regions + +
+ )} +
+ ) +} diff --git a/apps/web/src/components/Thermometer/SingleThermometer.tsx b/apps/web/src/components/Thermometer/SingleThermometer.tsx new file mode 100644 index 00000000..ae6aaa02 --- /dev/null +++ b/apps/web/src/components/Thermometer/SingleThermometer.tsx @@ -0,0 +1,124 @@ +'use client' + +import { css } from '@styled/css' +import type { SingleThermometerProps } from './types' + +/** + * A single-selection thermometer component that displays discrete options + * in a horizontal or vertical layout. Supports custom overlay rendering + * for additional visual indicators. + */ +export function SingleThermometer({ + options, + value, + onChange, + orientation = 'horizontal', + isDark = false, + label, + description, + renderOverlay, +}: SingleThermometerProps) { + const isHorizontal = orientation === 'horizontal' + + return ( +
+ {/* Label and description */} + {(label || description) && ( +
+ {label && ( +
+ {label} +
+ )} + {description && ( +
+ {description} +
+ )} +
+ )} + + {/* Thermometer track */} +
+ {options.map((option, index) => { + const isSelected = value === option.value + const isFirst = index === 0 + const isLast = index === options.length - 1 + + return ( + + ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/Thermometer/index.ts b/apps/web/src/components/Thermometer/index.ts new file mode 100644 index 00000000..90641bc6 --- /dev/null +++ b/apps/web/src/components/Thermometer/index.ts @@ -0,0 +1,9 @@ +export { SingleThermometer } from './SingleThermometer' +export { RangeThermometer } from './RangeThermometer' +export type { + ThermometerOption, + ThermometerBaseProps, + SingleThermometerProps, + RangeThermometerProps, + RangePreviewState, +} from './types' diff --git a/apps/web/src/components/Thermometer/types.ts b/apps/web/src/components/Thermometer/types.ts new file mode 100644 index 00000000..8ac1f726 --- /dev/null +++ b/apps/web/src/components/Thermometer/types.ts @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react' + +/** + * Generic option type for thermometer components + */ +export interface ThermometerOption { + value: T + label: string + shortLabel?: string + emoji?: string +} + +/** + * Common props shared between single and range thermometers + */ +export interface ThermometerBaseProps { + orientation?: 'horizontal' | 'vertical' + isDark?: boolean + label?: string + description?: string +} + +/** + * Props for single-selection thermometer + */ +export interface SingleThermometerProps extends ThermometerBaseProps { + options: ThermometerOption[] + value: T + onChange: (value: T) => void + /** Render custom content over an option (e.g., auto-resolve indicator) */ + renderOverlay?: (option: ThermometerOption, index: number, isSelected: boolean) => ReactNode +} + +/** + * Preview state when hovering over a range thermometer option + */ +export interface RangePreviewState { + /** The previewed min value (what would be selected if clicked) */ + previewMin: T + /** The previewed max value (what would be selected if clicked) */ + previewMax: T +} + +/** + * Props for range (dual-handle) thermometer + */ +export interface RangeThermometerProps extends ThermometerBaseProps { + options: ThermometerOption[] + minValue: T + maxValue: T + onChange: (min: T, max: T) => void + /** Optional counts to show per option */ + counts?: Partial> + /** Show total count of selected range */ + showTotalCount?: boolean + /** Called when hovering over an option to preview what would be selected */ + onHoverPreview?: (preview: RangePreviewState | null) => void +}