Skip to main content
Glama
northernvariables

FedMCP - Federal Parliamentary Information

SeatingChart.tsx10.4 kB
/** * SeatingChart Component * * SVG-based visualization of the House of Commons seating plan * - Color-coded circles by party (smaller, fits better) * - Opposition benches at top * - Government benches at bottom * - Speaker position * - Clickable seats with hover tooltips */ 'use client'; import React, { useMemo, useCallback } from 'react'; import { motion } from 'framer-motion'; interface MP { id: string; name: string; party: string; riding: string; photo_url?: string; cabinet_position?: string; seat_row?: number; seat_column?: number; bench_section?: string; seat_visual_x?: number; seat_visual_y?: number; } interface SeatingChartProps { mps: MP[]; onSeatClick?: (mp: MP) => void; highlightedMpId?: string; } // Party colors const PARTY_COLORS: Record<string, string> = { 'Conservative': '#002395', 'Liberal': '#D71920', 'Bloc Québécois': '#33B2CC', 'Bloc': '#33B2CC', // Database uses "Bloc" without "Québécois" 'NDP': '#F37021', 'New Democratic Party': '#F37021', 'Green Party': '#3D9B35', 'Green': '#3D9B35', // Database uses "Green" not "Green Party" 'Independent': '#666666', }; // Normalize party name to handle accent variations (Québécois vs Quebecois) // Moved outside component for better performance const normalizePartyName = (name: string): string => { return name.toLowerCase() .normalize('NFD').replace(/[\u0300-\u036f]/g, '') // Remove accents .trim(); }; // Get party color with fallback - moved outside component const getPartyColor = (party: string): string => { // Try exact match first if (PARTY_COLORS[party]) { return PARTY_COLORS[party]; } // Try normalized match const normalizedParty = normalizePartyName(party); for (const [key, color] of Object.entries(PARTY_COLORS)) { if (normalizePartyName(key) === normalizedParty) { return color; } } return PARTY_COLORS['Independent']; }; export function SeatingChart({ mps, onSeatClick, highlightedMpId }: SeatingChartProps) { const [hoveredMp, setHoveredMp] = React.useState<MP | null>(null); const [mousePosition, setMousePosition] = React.useState({ x: 0, y: 0 }); const svgRef = React.useRef<SVGSVGElement>(null); // Filter MPs with seating data - memoized to avoid recalculating on every render const seatedMps = useMemo(() => mps.filter(mp => mp.seat_visual_x !== undefined && mp.seat_visual_y !== undefined ), [mps]); // Separate by bench section - memoized const oppositionMps = useMemo(() => seatedMps.filter(mp => mp.bench_section === 'opposition'), [seatedMps] ); const governmentMps = useMemo(() => seatedMps.filter(mp => mp.bench_section === 'government'), [seatedMps] ); const speakerMp = useMemo(() => seatedMps.find(mp => mp.bench_section === 'speaker'), [seatedMps] ); // SVG dimensions - more compact const width = 1400; const height = 800; const seatRadius = 11; // Larger circles for better visibility const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => { const svg = e.currentTarget; const rect = svg.getBoundingClientRect(); const svgX = ((e.clientX - rect.left) / rect.width) * width; const svgY = ((e.clientY - rect.top) / rect.height) * height; setMousePosition({ x: e.clientX - rect.left, y: e.clientY - rect.top, }); }; // Memoized render function to avoid recreating on every render const renderSeat = useCallback((mp: MP, isSpeaker: boolean = false) => { const x = mp.seat_visual_x!; const y = mp.seat_visual_y!; const partyColor = isSpeaker ? '#8B4513' : getPartyColor(mp.party); const isHighlighted = mp.id === highlightedMpId; const isHovered = hoveredMp?.id === mp.id; const radius = isSpeaker ? seatRadius * 1.5 : seatRadius; return ( <g key={mp.id} transform={`translate(${x}, ${y})`} onClick={() => onSeatClick?.(mp)} onMouseEnter={() => setHoveredMp(mp)} onMouseLeave={() => setHoveredMp(null)} className="cursor-pointer transition-all" > {/* Main seat circle */} <circle r={radius} fill={partyColor} stroke={isHighlighted ? '#FFD700' : isHovered ? '#fff' : partyColor} strokeWidth={isHighlighted ? 3 : isHovered ? 2 : 1} opacity={isHovered ? 1 : 0.9} className="transition-all" /> {/* Cabinet indicator - small gold ring */} {mp.cabinet_position && !isSpeaker && ( <circle r={radius + 2} fill="none" stroke="#FFD700" strokeWidth={1.5} /> )} {/* Speaker indicator - gavel icon approximation */} {isSpeaker && ( <> <rect x={-3} y={-radius + 2} width={6} height={3} fill="#FFD700" /> <rect x={-1} y={-radius + 5} width={2} height={radius - 3} fill="#FFD700" /> </> )} {/* Highlight pulse animation */} {isHighlighted && ( <circle r={radius + 5} fill="none" stroke="#FFD700" strokeWidth={2} opacity={0.5} > <animate attributeName="r" from={radius} to={radius + 10} dur="1.5s" repeatCount="indefinite" /> <animate attributeName="opacity" from={0.8} to={0} dur="1.5s" repeatCount="indefinite" /> </circle> )} </g> ); }, [highlightedMpId, hoveredMp, onSeatClick, seatRadius]); return ( <div className="relative w-full"> <div className="max-w-6xl mx-auto"> {/* SVG Seating Chart */} <svg ref={svgRef} viewBox={`0 0 ${width} ${height}`} className="w-full h-auto bg-bg-secondary rounded-lg shadow-md border border-border-subtle" onMouseMove={handleMouseMove} onMouseLeave={() => setHoveredMp(null)} > {/* Center divider line */} <line x1={100} y1={height / 2} x2={width - 100} y2={height / 2} stroke="#666" strokeWidth={1} strokeDasharray="5,5" opacity={0.2} /> {/* Render Opposition Seats */} {oppositionMps.map(mp => renderSeat(mp))} {/* Render Government Seats */} {governmentMps.map(mp => renderSeat(mp))} {/* Render Speaker - Positioned on left side, vertically centered */} {speakerMp && renderSeat( { ...speakerMp, seat_visual_x: 60, seat_visual_y: height / 2 - 30 }, true )} </svg> {/* Legend */} <div className="mt-4 flex flex-wrap justify-center gap-4 text-xs"> {Object.entries(PARTY_COLORS) .filter(([party]) => !['New Democratic Party', 'Independent', 'Bloc', 'Green'].includes(party)) // Skip database aliases .map(([party, color]) => { // Count MPs for this party, including database aliases const count = seatedMps.filter(mp => { const mpParty = mp.party; // Direct match if (mpParty === party) return true; // Match database aliases if (party === 'Bloc Québécois' && mpParty === 'Bloc') return true; if (party === 'Green Party' && mpParty === 'Green') return true; if (party === 'NDP' && (mpParty === 'NDP' || mpParty === 'New Democratic Party')) return true; // Normalized match for accents return normalizePartyName(mpParty) === normalizePartyName(party); }).length; // Only skip if it's not a major party or if count is 0 AND it's not one of the main parties const isMajorParty = ['Liberal', 'Conservative', 'Bloc Québécois', 'NDP', 'Green Party'].includes(party); if (count === 0 && !isMajorParty) return null; return ( <div key={party} className="flex items-center gap-2"> <div className="w-4 h-4 rounded-full border border-border-subtle" style={{ backgroundColor: color }} /> <span className="text-text-secondary"> {party} <span className="font-semibold text-text-primary">({count})</span> </span> </div> ); })} {speakerMp && ( <div className="flex items-center gap-2"> <div className="w-4 h-4 rounded-full border border-border-subtle" style={{ backgroundColor: '#8B4513' }} /> <span className="text-text-secondary"> Speaker <span className="font-semibold text-text-primary">(1)</span> </span> </div> )} </div> </div> {/* Hover Tooltip */} {hoveredMp && ( <motion.div initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} className="fixed pointer-events-none z-50 bg-bg-elevated border border-border-subtle rounded-lg shadow-xl p-3 max-w-xs" style={{ left: mousePosition.x + 15, top: mousePosition.y + 15, }} > <div className="space-y-1"> <p className="font-semibold text-text-primary text-sm">{hoveredMp.name}</p> <p className="text-xs text-text-secondary">{hoveredMp.party}</p> <p className="text-xs text-text-tertiary">{hoveredMp.riding}</p> {hoveredMp.cabinet_position && ( <p className="text-xs text-accent-blue font-medium mt-1"> ⭐ {hoveredMp.cabinet_position} </p> )} {hoveredMp.bench_section === 'speaker' && ( <p className="text-xs text-accent-blue font-medium mt-1"> 🔨 Speaker of the House </p> )} </div> </motion.div> )} </div> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/northernvariables/FedMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server