/**
* CanadaMap Component
* Main SVG map wrapper with interactive provinces colored by dominant party
*/
'use client';
import React, { useEffect, useLayoutEffect, useRef, useCallback, useState } from 'react';
import { AnimatePresence } from 'framer-motion';
import { useVisualizerStore } from '@/hooks/useVisualizerStore';
import { provinceCodes, getProvinceName } from '@/lib/visualizer/provinceData';
import { useSeatDataContext } from '@/contexts/SeatDataContext';
import { getPartyColor } from '@/lib/visualizer/partyColors';
import { getEqualizationColor } from '@/lib/visualizer/equalization';
import { ProvinceTooltip } from './ProvinceTooltip';
import { MaritimeInset } from './MaritimeInset';
import { ProvinceLabels, ProvinceLabelData } from './ProvinceLabels';
// Hook to detect mobile screen size
function useIsMobile(breakpoint = 1280) {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < breakpoint);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, [breakpoint]);
return isMobile;
}
export interface CanadaMapProps {
/** Data to display under each province label */
labelData?: ProvinceLabelData;
/** Format function for label values */
formatLabelValue?: (value: string | number | undefined, provinceCode: string) => string;
}
export function CanadaMap({ labelData, formatLabelValue }: CanadaMapProps = {}) {
const containerRef = useRef<HTMLDivElement>(null);
const [svgContent, setSvgContent] = useState<string>('');
const [isLoaded, setIsLoaded] = useState(false);
const isMobile = useIsMobile();
const {
selectedProvince,
setSelectedProvince,
hoveredProvince,
setHoveredProvince,
setMousePosition,
visualizationType,
equalizationStep,
} = useVisualizerStore();
const { getDominantParty, loading: seatDataLoading } = useSeatDataContext();
const isEqualizationMode = visualizationType === 'equalization';
// Load SVG content
useEffect(() => {
fetch('/canada.svg')
.then(res => res.text())
.then(svg => {
setSvgContent(svg);
setIsLoaded(true);
})
.catch(err => console.error('Failed to load Canada SVG:', err));
}, []);
// Find province code from an element (uses closest() for reliable SVG traversal)
const findProvinceCode = useCallback((element: Element | null): string | null => {
if (!element) return null;
// Check for QC-islands first
if (element.closest('#QC-islands')) return 'QC';
// Check for province codes using closest() which works better with SVG
for (const code of provinceCodes) {
if (element.closest(`#${code}`)) return code;
}
return null;
}, []);
// Handle click on map container (event delegation)
const handleClick = useCallback((e: React.MouseEvent) => {
const provinceCode = findProvinceCode(e.target as Element);
if (provinceCode) {
const current = useVisualizerStore.getState().selectedProvince;
setSelectedProvince(current === provinceCode ? null : provinceCode);
}
}, [findProvinceCode, setSelectedProvince]);
// Handle mouse over on map container (event delegation)
const handleMouseOver = useCallback((e: React.MouseEvent) => {
const provinceCode = findProvinceCode(e.target as Element);
if (provinceCode) {
setHoveredProvince(provinceCode);
}
}, [findProvinceCode, setHoveredProvince]);
// Handle mouse out on map container
const handleMouseOut = useCallback((e: React.MouseEvent) => {
const relatedTarget = e.relatedTarget as Element | null;
// If leaving to nothing (outside window) or to an element outside the container, clear hover
if (!relatedTarget || !containerRef.current?.contains(relatedTarget)) {
setHoveredProvince(null);
return;
}
const provinceCode = findProvinceCode(e.target as Element);
const relatedProvince = findProvinceCode(relatedTarget);
// Only clear hover if leaving to non-province element within the container
if (provinceCode && provinceCode !== relatedProvince) {
setHoveredProvince(null);
}
}, [findProvinceCode, setHoveredProvince]);
// Handle mouse leaving the map container entirely
const handleMouseLeave = useCallback(() => {
setHoveredProvince(null);
}, [setHoveredProvince]);
// Handle mouse move for tooltip positioning
const handleMouseMove = useCallback((e: React.MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
}, [setMousePosition]);
// Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const target = e.target as Element;
const provinceCode = findProvinceCode(target);
if (provinceCode && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
const current = useVisualizerStore.getState().selectedProvince;
setSelectedProvince(current === provinceCode ? null : provinceCode, 'map');
}
}, [findProvinceCode, setSelectedProvince]);
// Handle focus for accessibility
const handleFocus = useCallback((e: React.FocusEvent) => {
const provinceCode = findProvinceCode(e.target as Element);
if (provinceCode) {
setHoveredProvince(provinceCode);
}
}, [findProvinceCode, setHoveredProvince]);
// Handle blur for accessibility
const handleBlur = useCallback(() => {
setHoveredProvince(null);
}, [setHoveredProvince]);
// Use native DOM click listener (required for dangerouslySetInnerHTML content)
// Also set up keyboard accessibility for province elements
// Using useLayoutEffect to ensure ref is populated after DOM mutations
useLayoutEffect(() => {
const container = containerRef.current;
const isReady = isLoaded && !seatDataLoading;
if (!container || !isReady) return;
const nativeClickHandler = (e: MouseEvent) => {
const provinceCode = findProvinceCode(e.target as Element);
if (provinceCode) {
const current = useVisualizerStore.getState().selectedProvince;
setSelectedProvince(current === provinceCode ? null : provinceCode, 'map');
}
};
// Make province elements keyboard-focusable
provinceCodes.forEach(code => {
const element = container.querySelector(`#${code}`);
if (element) {
element.setAttribute('tabindex', '0');
element.setAttribute('role', 'button');
element.setAttribute('aria-label', `${getProvinceName(code, 'en')}. Press Enter to select.`);
}
});
// Also make QC-islands focusable
const qcIslands = container.querySelector('#QC-islands');
if (qcIslands) {
qcIslands.setAttribute('tabindex', '0');
qcIslands.setAttribute('role', 'button');
qcIslands.setAttribute('aria-label', 'Quebec Islands. Press Enter to select Quebec.');
}
container.addEventListener('click', nativeClickHandler);
return () => {
container.removeEventListener('click', nativeClickHandler);
};
}, [isLoaded, seatDataLoading, findProvinceCode, setSelectedProvince]);
// Reorder SVG content to move selected province to end (renders on top)
const getModifiedSvgContent = useCallback(() => {
if (!svgContent || !selectedProvince) return svgContent;
// Parse the SVG and move selected province group to end
const parser = new DOMParser();
const doc = parser.parseFromString(svgContent, 'image/svg+xml');
const svg = doc.querySelector('svg');
if (!svg) return svgContent;
const provinceGroup = svg.querySelector(`#${selectedProvince}`);
if (provinceGroup && provinceGroup.parentNode) {
provinceGroup.parentNode.appendChild(provinceGroup);
}
// Also move QC-islands if Quebec is selected
if (selectedProvince === 'QC') {
const qcIslands = svg.querySelector('#QC-islands');
if (qcIslands && qcIslands.parentNode) {
qcIslands.parentNode.appendChild(qcIslands);
}
}
return new XMLSerializer().serializeToString(svg);
}, [svgContent, selectedProvince]);
// Get color for a province based on current visualization mode
const getProvinceColor = useCallback((code: string): string => {
if (isEqualizationMode) {
return getEqualizationColor(code, equalizationStep);
}
return getPartyColor(getDominantParty(code));
}, [isEqualizationMode, equalizationStep, getDominantParty]);
// Generate dynamic styles for provinces
const generateProvinceStyles = () => {
// Base SVG styles - set background to match stroke color to hide anti-aliasing gaps
// Also override any default cls-2 fills that might show light colors
// IMPORTANT: Scope to .canada-map-container to avoid affecting other SVGs (like chat button)
const baseStyles = `
.canada-map-container svg {
background-color: #1f2937;
}
.canada-map-container .cls-2 {
fill: #1f2937 !important;
}
/* Focus styles for keyboard accessibility */
.canada-map-container [tabindex="0"]:focus {
outline: none;
}
.canada-map-container [tabindex="0"]:focus-visible polygon,
.canada-map-container [tabindex="0"]:focus-visible path {
stroke: #60A5FA !important;
stroke-width: 4px !important;
filter: drop-shadow(0 0 6px rgba(96, 165, 250, 0.8));
}
`;
const provinceStyles = provinceCodes.map(code => {
const provinceColor = getProvinceColor(code);
const isSelected = selectedProvince === code;
return `
#${code} {
cursor: pointer;
transition: all 0.2s ease;
pointer-events: all;
}
#${code} polygon,
#${code} path,
#${code} .cls-1,
#${code} .cls-2,
#${code} .cls-3,
#${code} .cls-4 {
fill: ${provinceColor} !important;
stroke: #1f2937 !important;
stroke-width: 2px !important;
transition: all 0.2s ease;
pointer-events: all;
}
#${code}:hover polygon,
#${code}:hover path,
#${code}:hover .cls-1,
#${code}:hover .cls-2,
#${code}:hover .cls-3,
#${code}:hover .cls-4 {
filter: brightness(1.15);
}
${isSelected ? `
#${code} {
isolation: isolate;
}
#${code} polygon,
#${code} path,
#${code} .cls-1,
#${code} .cls-2,
#${code} .cls-3,
#${code} .cls-4 {
stroke: #FFD700 !important;
stroke-width: 6px !important;
paint-order: fill stroke;
filter: brightness(1.1) drop-shadow(0 0 8px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 16px rgba(255, 215, 0, 0.5));
}
` : ''}
`;
}).join('\n');
// QC-islands styling (Anticosti & Magdalen Islands) - same as Quebec
const qcProvinceColor = getProvinceColor('QC');
const qcIslandsSelected = selectedProvince === 'QC';
const qcIslandsStyles = `
#QC-islands {
cursor: pointer;
transition: all 0.2s ease;
pointer-events: all;
}
#QC-islands polygon,
#QC-islands path {
fill: ${qcProvinceColor} !important;
stroke: #1f2937 !important;
stroke-width: 2px !important;
transition: all 0.2s ease;
pointer-events: all;
}
#QC-islands:hover polygon,
#QC-islands:hover path {
filter: brightness(1.15);
}
${qcIslandsSelected ? `
#QC-islands {
isolation: isolate;
}
#QC-islands polygon,
#QC-islands path {
stroke: #FFD700 !important;
stroke-width: 6px !important;
paint-order: fill stroke;
filter: brightness(1.1) drop-shadow(0 0 8px rgba(255, 215, 0, 0.8)) drop-shadow(0 0 16px rgba(255, 215, 0, 0.5));
}
` : ''}
`;
return baseStyles + provinceStyles + qcIslandsStyles;
};
const isLoading = !isLoaded || seatDataLoading;
// Generate screen reader announcement for selected province
const getSelectionAnnouncement = () => {
if (!selectedProvince) return '';
return `${getProvinceName(selectedProvince, 'en')} selected`;
};
return (
<div className="relative w-full h-full min-h-0">
{/* Dynamic province styles */}
{!isLoading && <style>{generateProvinceStyles()}</style>}
{/* Screen reader announcement for selection changes */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{getSelectionAnnouncement()}
</div>
{/* Map container - always rendered so ref is available */}
<div
ref={containerRef}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
role="application"
aria-label="Interactive map of Canada showing federal seat distribution by province. Use Tab to navigate between provinces and Enter to select."
className="canada-map-container w-full h-full max-h-full rounded-lg border border-border-subtle overflow-hidden p-3 xl:p-5"
style={{ backgroundColor: '#1f2937' }}
>
{isLoading ? (
<div className="w-full aspect-[1.2/1] bg-bg-secondary rounded-lg animate-pulse flex items-center justify-center">
<span className="text-text-tertiary">Loading map...</span>
</div>
) : (
<div className="h-full w-full flex items-center justify-center">
{/* Wrapper with fixed aspect ratio matching the SVG to keep labels aligned */}
<div
className="relative w-full max-h-full"
style={{ aspectRatio: '1.25 / 1' }}
>
<div
className="absolute inset-0 [&_svg]:w-full [&_svg]:h-full [&_svg_*]:pointer-events-auto"
dangerouslySetInnerHTML={{ __html: getModifiedSvgContent() }}
/>
<ProvinceLabels labelData={labelData} formatValue={formatLabelValue} />
</div>
</div>
)}
</div>
{/* Maritime inset */}
<MaritimeInset />
{/* Province tooltip - hidden on mobile, shows for hovered or selected province */}
<AnimatePresence>
{(hoveredProvince || selectedProvince) && !isMobile && <ProvinceTooltip />}
</AnimatePresence>
</div>
);
}