jcbowyer's picture
Deploy: Consolidated gold tables, fixed nginx docs routing
6dcf9fc verified
// @ts-nocheck
import { useState, useRef, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { ComposableMap, Geographies, Geography } from 'react-simple-maps'
const geoUrl = 'https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json'
interface BillSample {
bill_number: string
title: string
status: string
type: string
action: string
date?: string // Format: "Jan 2026"
state: string
}
interface StateData {
state: string
total_bills: number
type_counts: {
ban: number
restriction: number
protection: number
other: number
}
status_counts: {
enacted: number
failed: number
pending: number
}
primary_type: string
primary_status: string
map_category: string
sample_bills?: BillSample[]
}
interface USMapProps {
stateData: Record<string, StateData>
onStateClick?: (stateCode: string) => void
legend?: {
types: Record<string, string>
statuses: Record<string, string>
}
}
// State code to FIPS mapping
const STATE_FIPS: Record<string, string> = {
'AL': '01', 'AK': '02', 'AZ': '04', 'AR': '05', 'CA': '06',
'CO': '08', 'CT': '09', 'DE': '10', 'FL': '12', 'GA': '13',
'HI': '15', 'ID': '16', 'IL': '17', 'IN': '18', 'IA': '19',
'KS': '20', 'KY': '21', 'LA': '22', 'ME': '23', 'MD': '24',
'MA': '25', 'MI': '26', 'MN': '27', 'MS': '28', 'MO': '29',
'MT': '30', 'NE': '31', 'NV': '32', 'NH': '33', 'NJ': '34',
'NM': '35', 'NY': '36', 'NC': '37', 'ND': '38', 'OH': '39',
'OK': '40', 'OR': '41', 'PA': '42', 'RI': '44', 'SC': '45',
'SD': '46', 'TN': '47', 'TX': '48', 'UT': '49', 'VT': '50',
'VA': '51', 'WA': '53', 'WV': '54', 'WI': '55', 'WY': '56',
'DC': '11', 'PR': '72'
}
const FIPS_STATE: Record<string, string> = Object.fromEntries(
Object.entries(STATE_FIPS).map(([k, v]) => [v, k])
)
// Flexible color palette for different legislation types
const TYPE_COLOR_PALETTE: Record<string, string> = {
// Fluoridation categories
'mandate': '#4CAF50', // Green - Mandate
'removal': '#F44336', // Red - Removal
'study': '#9C27B0', // Purple - Study
// Dental/Oral Health categories
'coverage_expansion': '#4CAF50', // Green - Expansion
'screening': '#FF9800', // Orange - Screening
'provider_access': '#2196F3', // Blue - Provider Access
// Medicaid categories
'expansion': '#4CAF50', // Green - Expansion
'coverage': '#2196F3', // Blue - Coverage
'reimbursement': '#FF9800', // Orange - Reimbursement
'eligibility': '#9C27B0', // Purple - Eligibility
// Education categories
'requirement': '#FF9800', // Orange - Requirement
'curriculum': '#2196F3', // Blue - Curriculum
'reform': '#9C27B0', // Purple - Reform
// Health/General categories
'protection': '#4CAF50', // Green - Protection
'restriction': '#F44336', // Red - Restriction
// Generic categories
'support': '#4CAF50', // Green - Support
'oppose': '#F44336', // Red - Oppose
'regulate': '#FF9800', // Orange - Regulate
// Shared
'funding': '#2196F3', // Blue - Funding (appears in multiple)
'other': '#9E9E9E', // Gray - Other
}
// Get color for any category with fallback
const getColorForCategory = (category: string): string => {
return TYPE_COLOR_PALETTE[category] || '#9E9E9E' // Default to gray
}
// Color scheme based on the user's description
const getStateColor = (stateCode: string, stateData: Record<string, StateData>): string => {
const data = stateData[stateCode]
if (!data || data.total_bills === 0) {
return '#E3F2FD' // Light blue - no legislation
}
const { primary_type, primary_status } = data
// Get base color for this type
let baseColor = getColorForCategory(primary_type)
// Adjust shade based on status
if (primary_status === 'enacted') {
// Slightly darker for enacted (reduce lightness)
return adjustColorBrightness(baseColor, -20)
} else if (primary_status === 'failed') {
// Lighter for failed (increase lightness)
return adjustColorBrightness(baseColor, 40)
}
return baseColor
}
// Helper to adjust color brightness
const adjustColorBrightness = (hex: string, percent: number): string => {
// Simple brightness adjustment
const num = parseInt(hex.replace('#', ''), 16)
const amt = Math.round(2.55 * percent)
const R = (num >> 16) + amt
const G = (num >> 8 & 0x00FF) + amt
const B = (num & 0x0000FF) + amt
return '#' + (
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
).toString(16).slice(1).toUpperCase()
}
const getPatternForState = (stateCode: string, stateData: Record<string, StateData>): string | null => {
const data = stateData[stateCode]
if (!data || data.total_bills === 0) {
return null
}
const { primary_status } = data
if (primary_status === 'failed') {
return 'crosshatch'
} else if (primary_status === 'enacted') {
return 'diagonal'
}
return null
}
export default function USMap({ stateData, onStateClick, legend }: USMapProps) {
const [hoveredState, setHoveredState] = useState<string | null>(null)
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 })
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const hoveredStateElementRef = useRef<any>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Get unique types from actual state data if legend not provided
const legislationTypes = legend?.types || {}
const legislationStatuses = legend?.statuses || {
'enacted': 'Enacted',
'failed': 'Failed',
'pending': 'Pending'
}
// Helper to calculate position relative to container
const calculateRelativePosition = (element: any) => {
if (!element || !containerRef.current) return { x: 0, y: 0 }
const stateBounds = element.getBoundingClientRect()
const containerBounds = containerRef.current.getBoundingClientRect()
return {
x: stateBounds.left - containerBounds.left + stateBounds.width / 2,
y: stateBounds.top - containerBounds.top
}
}
// Update tooltip position on scroll
useEffect(() => {
const updateTooltipPosition = () => {
if (hoveredState && hoveredStateElementRef.current) {
setTooltipPosition(calculateRelativePosition(hoveredStateElementRef.current))
}
}
if (hoveredState) {
window.addEventListener('scroll', updateTooltipPosition, true)
return () => window.removeEventListener('scroll', updateTooltipPosition, true)
}
}, [hoveredState])
const handleMouseEnter = (event: any, stateCode: string) => {
// Clear any pending state change
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
// Store the element reference for scroll updates
hoveredStateElementRef.current = event.target
// If no tooltip showing, show immediately
if (!hoveredState) {
setHoveredState(stateCode)
setTooltipPosition(calculateRelativePosition(event.target))
}
// If tooltip already showing for different state, delay switch
// This prevents accidental switches when moving mouse to tooltip
else if (hoveredState !== stateCode) {
hoverTimeoutRef.current = setTimeout(() => {
setHoveredState(stateCode)
setTooltipPosition(calculateRelativePosition(event.target))
}, 200) // 200ms delay prevents accidental switches
}
}
const handleMouseLeave = () => {
// Clear any pending state change when leaving
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
}
// Close button handler
const handleCloseTooltip = () => {
setHoveredState(null)
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
}
const hoveredData = hoveredState ? stateData[hoveredState] : null
return (
<div ref={containerRef} className="relative">
{/* SVG Patterns for overlays */}
<svg width="0" height="0">
<defs>
{/* Crosshatch pattern for failed */}
<pattern id="crosshatch" width="10" height="10" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="10" y2="10" stroke="#000" strokeWidth="1" opacity="0.3" />
<line x1="10" y1="0" x2="0" y2="10" stroke="#000" strokeWidth="1" opacity="0.3" />
</pattern>
{/* Diagonal stripes for enacted */}
<pattern id="diagonal" width="10" height="10" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="10" y2="10" stroke="#000" strokeWidth="2" opacity="0.4" />
</pattern>
</defs>
</svg>
<ComposableMap
projection="geoAlbersUsa"
projectionConfig={{
scale: 1000,
}}
className="w-full h-auto"
>
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies.map((geo) => {
const fips = geo.id
const stateCode = FIPS_STATE[fips] || fips
const data = stateData[stateCode]
const pattern = getPatternForState(stateCode, stateData)
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={pattern ? `url(#${pattern})` : getStateColor(stateCode, stateData)}
stroke="#FFFFFF"
strokeWidth={0.5}
style={{
default: {
fill: getStateColor(stateCode, stateData),
outline: 'none',
},
hover: {
fill: '#607D8B',
outline: 'none',
cursor: 'pointer',
},
pressed: {
fill: '#455A64',
outline: 'none',
},
}}
onClick={() => onStateClick?.(stateCode)}
onMouseEnter={(event) => handleMouseEnter(event, stateCode)}
onMouseLeave={handleMouseLeave}
/>
)
})
}
</Geographies>
</ComposableMap>
{/* Tooltip - Stays visible until hover another state or click close */}
{hoveredState && hoveredData && (
<div
className="absolute z-50 pointer-events-auto"
style={{
left: `${tooltipPosition.x}px`,
top: `${tooltipPosition.y - 10}px`,
transform: 'translate(-50%, -100%)'
}}
>
<div className="bg-gray-900 text-white px-4 py-3 rounded-lg shadow-xl max-w-sm border border-gray-700">
{/* Close button */}
<button
onClick={handleCloseTooltip}
className="absolute top-2 right-2 text-gray-400 hover:text-white transition-colors"
aria-label="Close tooltip"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex items-center justify-between mb-3">
<div className="font-bold text-lg">{hoveredState}</div>
<div className="text-xs text-gray-400">
{hoveredData.total_bills.toLocaleString()} bill{hoveredData.total_bills !== 1 ? 's' : ''}
</div>
</div>
{hoveredData.total_bills > 0 && (
<>
{/* Primary Type and Status - Prominent */}
<div className="mb-3 p-2 bg-gray-800 rounded-md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`
w-3 h-3 rounded-full
${hoveredData.primary_type === 'removal' ? 'bg-red-500' :
hoveredData.primary_type === 'mandate' ? 'bg-green-500' :
hoveredData.primary_type === 'study' ? 'bg-blue-500' :
hoveredData.primary_type === 'funding' ? 'bg-yellow-500' :
'bg-gray-500'}
`} />
<div>
<div className="text-sm font-semibold text-white">
{legislationTypes[hoveredData.primary_type] || hoveredData.primary_type}
</div>
<div className="text-xs text-gray-400">Primary Type</div>
</div>
</div>
<div className={`
px-2 py-1 rounded text-xs font-medium
${hoveredData.primary_status === 'enacted' ? 'bg-green-500/30 text-green-300 border border-green-500/50' :
hoveredData.primary_status === 'failed' ? 'bg-red-500/30 text-red-300 border border-red-500/50' :
'bg-yellow-500/30 text-yellow-300 border border-yellow-500/50'}
`}>
{hoveredData.primary_status === 'enacted' ? '✓ Enacted' :
hoveredData.primary_status === 'failed' ? '✗ Failed' :
'⏳ Pending'}
</div>
</div>
</div>
{/* Sample Bills Grouped by Type */}
{hoveredData.sample_bills && hoveredData.sample_bills.length > 0 && (
<div className="mb-3 max-h-48 overflow-y-auto">
<div className="text-xs font-medium text-gray-300 mb-2">Recent Bills by Type:</div>
<div className="space-y-2">
{(() => {
// Group bills by type
const billsByType = hoveredData.sample_bills.reduce((acc, bill) => {
if (!acc[bill.type]) acc[bill.type] = []
acc[bill.type].push(bill)
return acc
}, {} as Record<string, BillSample[]>)
return Object.entries(billsByType).map(([type, bills]) => (
<div key={type} className="bg-gray-800/50 rounded p-2">
<div className="flex items-center gap-2 mb-1.5">
<div className={`
w-2 h-2 rounded-full
${type === 'removal' ? 'bg-red-500' :
type === 'mandate' ? 'bg-green-500' :
type === 'study' ? 'bg-blue-500' :
type === 'funding' ? 'bg-yellow-500' :
'bg-gray-500'}
`} />
<div className="text-xs font-medium text-gray-200">
{legislationTypes[type] || type} ({bills.length})
</div>
</div>
<div className="space-y-1 ml-4">
{bills.map((bill, idx) => (
<Link
key={idx}
to={`/bill/${bill.state}-${bill.bill_number}`}
className="block text-xs hover:bg-gray-700/50 rounded px-2 py-1 transition-colors group"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-blue-300 group-hover:text-blue-200 font-semibold">
{bill.bill_number}
</span>
<span className={`
px-1.5 py-0.5 rounded text-[10px] font-medium
${bill.status === 'enacted' ? 'bg-green-500/20 text-green-300' :
bill.status === 'failed' ? 'bg-red-500/20 text-red-300' :
'bg-yellow-500/20 text-yellow-300'}
`}>
{bill.status === 'enacted' ? '✓ Enacted' :
bill.status === 'failed' ? '✗ Failed' : '⏳ Pending'}
</span>
</div>
<div className="text-gray-400 text-[10px] mt-0.5 flex items-center gap-1">
{bill.date && <span className="text-gray-500">📅 {bill.date}</span>}
{bill.date && bill.action && <span className="text-gray-600"></span>}
<span className="flex-1 truncate">{bill.action || 'Click for details'}</span>
</div>
</Link>
))}
</div>
</div>
))
})()}
</div>
</div>
)}
{/* Status Summary */}
<div className="grid grid-cols-3 gap-2 mb-3 text-xs">
<div className="bg-green-500/10 border border-green-500/30 rounded px-2 py-1.5">
<div className="text-green-400 font-bold text-base">{hoveredData.status_counts.enacted}</div>
<div className="text-green-300/70">Enacted</div>
</div>
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded px-2 py-1.5">
<div className="text-yellow-400 font-bold text-base">{hoveredData.status_counts.pending}</div>
<div className="text-yellow-300/70">Pending</div>
</div>
<div className="bg-red-500/10 border border-red-500/30 rounded px-2 py-1.5">
<div className="text-red-400 font-bold text-base">{hoveredData.status_counts.failed}</div>
<div className="text-red-300/70">Failed</div>
</div>
</div>
{/* Drill Down Button */}
<button
onClick={() => onStateClick && onStateClick(hoveredState)}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium text-sm py-2 px-3 rounded transition-colors flex items-center justify-center gap-2"
>
<span>View All Bills</span>
<span className="text-lg"></span>
</button>
</>
)}
{hoveredData.total_bills === 0 && (
<div className="text-gray-400 text-sm italic text-center py-2">No legislation found</div>
)}
{/* Tooltip arrow */}
<div
className="absolute left-1/2 bottom-0 transform -translate-x-1/2 translate-y-full"
style={{
width: 0,
height: 0,
borderLeft: '8px solid transparent',
borderRight: '8px solid transparent',
borderTop: '8px solid rgb(17, 24, 39)'
}}
/>
</div>
</div>
)}
{/* Legend - Absolute on desktop, below map on mobile */}
<div className="relative md:absolute md:bottom-4 md:right-4 mt-4 md:mt-0 bg-white/95 rounded-lg shadow-lg p-4 border border-gray-200 max-w-xs mx-auto md:mx-0">
<div className="text-sm font-semibold text-gray-800 mb-3">Legend</div>
{/* Type of Legislation - Show actual types from data */}
{(() => {
// Get unique types from actual state data
const uniqueTypes = new Set<string>()
Object.values(stateData).forEach(state => {
if (state.primary_type) uniqueTypes.add(state.primary_type)
})
return uniqueTypes.size > 0 && (
<div className="mb-3">
<div className="text-xs font-medium text-gray-600 mb-2">Type of Legislation</div>
<div className="space-y-1">
{Array.from(uniqueTypes).sort().map(type => (
<div key={type} className="flex items-center gap-2">
<div className="w-4 h-4 rounded flex-shrink-0" style={{ backgroundColor: getColorForCategory(type) }} />
<span className="text-xs text-gray-700 capitalize">{legislationTypes[type] || type.replace(/_/g, ' ')}</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded flex-shrink-0" style={{ backgroundColor: '#E3F2FD' }} />
<span className="text-xs text-gray-700">No Legislation</span>
</div>
</div>
</div>
)
})()}
{/* Status of Legislation */}
<div>
<div className="text-xs font-medium text-gray-600 mb-2">Status</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-gray-300 flex-shrink-0" style={{ backgroundColor: '#666' }} />
<span className="text-xs text-gray-700">Enacted (darker)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-gray-300 flex-shrink-0 bg-white" />
<span className="text-xs text-gray-700">Pending (normal)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-gray-300 flex-shrink-0" style={{ backgroundColor: '#ddd' }} />
<span className="text-xs text-gray-700">Failed (lighter)</span>
</div>
</div>
</div>
</div>
</div>
)
}