circuit-bricks
Version:
A modular, Lego-style SVG circuit component system for React (ALPHA - Not for production use)
1 lines • 211 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","names":["resistorSchema","capacitorSchema","groundSchema","switchSchema","batterySchema","voltageSourceSchema","diodeSchema","transistorNpnSchema","ledSchema","icSchema","componentRegistry: Record<string, ComponentSchema>","schema: ComponentSchema","id: string","id","category: string","category","Port: React.FC<PortProps>","event: React.MouseEvent","BaseComponent: React.FC<BaseComponentProps>","id","Port","Brick: React.FC<BrickProps>","BaseComponent","componentId: string","portId: string","currentEl: Element | null","svgRoot: SVGSVGElement | null","WirePath: React.FC<WirePathProps>","from: Point","to: Point","CircuitCanvas: React.FC<CircuitCanvasProps>","clientX: number","clientY: number","e: React.MouseEvent | React.DragEvent","e: React.MouseEvent","id: string","event: React.MouseEvent","id","componentId: string","event: React.WheelEvent","portId: string","from: Point","to: Point","event: React.DragEvent","e: KeyboardEvent","WirePath","Brick","initialState?: Partial<CircuitState>","prevState: CircuitState","updater: (prev: CircuitState) => CircuitState","prefix: string","component: Omit<ComponentInstance, 'id'>","id","id: string","updates: Partial<ComponentInstance>","from: Wire['from']","to: Wire['to']","style?: Wire['style']","updates: Partial<Wire>","addToSelection: boolean","newPosition: Point","dx: number","dy: number","degrees: number","componentId: string","portId: string","toComponentId: string","toPortId: string","fromId: string","fromPortId: string","toId: string","actions: CircuitActions","wire: Wire | null","deps: any[]","componentId: string | null","portId: string | null","PropertyPanel: React.FC<PropertyPanelProps>","key: string","event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>","value: any","ComponentPalette: React.FC<ComponentPaletteProps>","category","ComponentListItem: React.FC<ComponentListItemProps>","event: React.DragEvent","CircuitToolbar: React.FC<CircuitToolbarProps>","SimpleCircuitExample: React.FC","id: string","id","key: string","value: any","componentType: string","action: string","CircuitToolbar","ComponentPalette","CircuitCanvas","PropertyPanel","circuit: CircuitState","issues: ValidationIssue[]","InteractiveCircuitExample: React.FC","componentType: string","id","position: Point","action: string","id: string","key: string","value: any","componentId: string","portId: string","event: React.MouseEvent","CircuitToolbar","ComponentPalette","CircuitCanvas","PropertyPanel","VoltageRegulatorExample: React.FC","CircuitCanvas","TimerCircuitExample: React.FC","CircuitCanvas"],"sources":["../src/registry/components/resistor.json","../src/registry/components/capacitor.json","../src/registry/components/battery.json","../src/registry/components/ground.json","../src/registry/components/switch.json","../src/registry/components/voltage-source.json","../src/registry/components/diode.json","../src/registry/components/transistor-npn.json","../src/registry/components/led.json","../src/registry/components/ic.json","../src/registry/components/index.ts","../src/registry/index.ts","../src/core/Port.tsx","../src/core/BaseComponent.tsx","../src/core/Brick.tsx","../src/utils/getPortPosition.ts","../src/core/WirePath.tsx","../src/core/CircuitCanvas.tsx","../src/hooks/useCircuit.ts","../src/hooks/usePortPosition.ts","../src/ui/PropertyPanel.tsx","../src/ui/ComponentPalette.tsx","../src/ui/CircuitToolbar.tsx","../src/examples/SimpleCircuitExample.tsx","../src/utils/circuitValidation.ts","../src/examples/InteractiveCircuitExample.tsx","../src/examples/VoltageRegulatorExample.tsx","../src/examples/TimerCircuitExample.tsx","../src/index.ts"],"sourcesContent":["{\n \"id\": \"resistor\",\n \"name\": \"Resistor\",\n \"category\": \"passive\",\n \"description\": \"A passive two-terminal electrical component that implements electrical resistance\",\n \"defaultWidth\": 80,\n \"defaultHeight\": 30,\n \"ports\": [\n {\n \"id\": \"left\",\n \"x\": 0,\n \"y\": 15,\n \"type\": \"inout\"\n },\n {\n \"id\": \"right\",\n \"x\": 80,\n \"y\": 15,\n \"type\": \"inout\"\n }\n ],\n \"properties\": [\n {\n \"key\": \"resistance\",\n \"label\": \"Resistance\",\n \"type\": \"number\",\n \"unit\": \"Ω\",\n \"default\": 1000,\n \"min\": 0\n },\n {\n \"key\": \"tolerance\",\n \"label\": \"Tolerance\",\n \"type\": \"number\",\n \"unit\": \"%\",\n \"default\": 5,\n \"min\": 0,\n \"max\": 20\n }\n ],\n \"svgPath\": \"M10,15 h10 l5,-10 l10,20 l10,-20 l10,20 l10,-20 l5,10 h10\"\n}\n","{\n \"id\": \"capacitor\",\n \"name\": \"Capacitor\",\n \"category\": \"passive\",\n \"description\": \"A passive two-terminal electrical component that stores electrical energy in an electric field\",\n \"defaultWidth\": 60,\n \"defaultHeight\": 40,\n \"ports\": [\n {\n \"id\": \"left\",\n \"x\": 0,\n \"y\": 20,\n \"type\": \"inout\"\n },\n {\n \"id\": \"right\",\n \"x\": 60,\n \"y\": 20,\n \"type\": \"inout\"\n }\n ],\n \"properties\": [\n {\n \"key\": \"capacitance\",\n \"label\": \"Capacitance\",\n \"type\": \"number\",\n \"unit\": \"μF\",\n \"default\": 10,\n \"min\": 0\n },\n {\n \"key\": \"voltageRating\",\n \"label\": \"Voltage Rating\",\n \"type\": \"number\",\n \"unit\": \"V\",\n \"default\": 50,\n \"min\": 0\n }\n ],\n \"svgPath\": \"M10,20 h15 M35,5 v30 M45,5 v30 M45,20 h15\"\n}\n","{\n \"id\": \"battery\",\n \"name\": \"Battery\",\n \"category\": \"sources\",\n \"description\": \"A source of electrical energy that can be used to power circuits\",\n \"defaultWidth\": 60,\n \"defaultHeight\": 40,\n \"ports\": [\n {\n \"id\": \"positive\",\n \"x\": 60,\n \"y\": 20,\n \"type\": \"output\"\n },\n {\n \"id\": \"negative\",\n \"x\": 0,\n \"y\": 20,\n \"type\": \"output\"\n }\n ],\n \"properties\": [\n {\n \"key\": \"voltage\",\n \"label\": \"Voltage\",\n \"type\": \"number\",\n \"unit\": \"V\",\n \"default\": 9,\n \"min\": 0\n },\n {\n \"key\": \"internal_resistance\",\n \"label\": \"Internal Resistance\",\n \"type\": \"number\",\n \"unit\": \"Ω\",\n \"default\": 0.1,\n \"min\": 0\n }\n ],\n \"svgPath\": \"M10,20 h10 M20,5 v30 M30,10 v20 M40,5 v30 M40,20 h10\"\n}\n","{\n \"id\": \"ground\",\n \"name\": \"Ground\",\n \"category\": \"basic\",\n \"description\": \"A reference point in an electrical circuit from which voltages are measured\",\n \"defaultWidth\": 30,\n \"defaultHeight\": 30,\n \"ports\": [\n {\n \"id\": \"terminal\",\n \"x\": 15,\n \"y\": 0,\n \"type\": \"inout\"\n }\n ],\n \"properties\": [],\n \"svgPath\": \"M15,0 v15 M5,15 h20 M8,20 h14 M11,25 h8\"\n}\n","{\n \"id\": \"switch\",\n \"name\": \"Switch\",\n \"category\": \"basic\",\n \"description\": \"A component that can be open or closed to control current flow\",\n \"defaultWidth\": 60,\n \"defaultHeight\": 30,\n \"ports\": [\n {\n \"id\": \"left\",\n \"x\": 0,\n \"y\": 15,\n \"type\": \"inout\"\n },\n {\n \"id\": \"right\",\n \"x\": 60,\n \"y\": 15,\n \"type\": \"inout\"\n }\n ],\n \"properties\": [\n {\n \"key\": \"state\",\n \"label\": \"State\",\n \"type\": \"select\",\n \"options\": [\n { \"value\": \"open\", \"label\": \"Open\" },\n { \"value\": \"closed\", \"label\": \"Closed\" }\n ],\n \"default\": \"open\"\n }\n ],\n \"svgPath\": \"M10,15 h10 M20,15 l20,-10 M40,15 h10\"\n}\n","{\n \"id\": \"voltage-source\",\n \"name\": \"Voltage Source\",\n \"category\": \"sources\",\n \"description\": \"An ideal voltage source with constant voltage\",\n \"defaultWidth\": 50,\n \"defaultHeight\": 50,\n \"ports\": [\n {\n \"id\": \"positive\",\n \"x\": 25,\n \"y\": 0,\n \"type\": \"output\"\n },\n {\n \"id\": \"negative\",\n \"x\": 25,\n \"y\": 50,\n \"type\": \"output\"\n }\n ],\n \"properties\": [\n {\n \"key\": \"voltage\",\n \"label\": \"Voltage\",\n \"type\": \"number\",\n \"unit\": \"V\",\n \"default\": 5,\n \"min\": 0\n },\n {\n \"key\": \"frequency\",\n \"label\": \"Frequency\",\n \"type\": \"number\",\n \"unit\": \"Hz\",\n \"default\": 0,\n \"min\": 0\n },\n {\n \"key\": \"sourceType\",\n \"label\": \"Source Type\",\n \"type\": \"select\",\n \"options\": [\n { \"value\": \"dc\", \"label\": \"DC\" },\n { \"value\": \"ac\", \"label\": \"AC\" }\n ],\n \"default\": \"dc\"\n }\n ],\n \"svgPath\": \"M25,10 v10 M20,25 h10 M25,30 v10 M25,25 m-15,0 a15,15 0 1,0 30,0 a15,15 0 1,0 -30,0\"\n}\n","{\n \"id\": \"diode\",\n \"name\": \"Diode\",\n \"category\": \"semiconductor\",\n \"description\": \"A semiconductor device that allows current to flow in only one direction\",\n \"defaultWidth\": 60,\n \"defaultHeight\": 30,\n \"ports\": [\n {\n \"id\": \"anode\",\n \"x\": 0,\n \"y\": 15,\n \"type\": \"inout\"\n },\n {\n \"id\": \"cathode\",\n \"x\": 60,\n \"y\": 15,\n \"type\": \"inout\"\n }\n ],\n \"properties\": [\n {\n \"key\": \"forwardVoltage\",\n \"label\": \"Forward Voltage\",\n \"type\": \"number\",\n \"unit\": \"V\",\n \"default\": 0.7,\n \"min\": 0\n },\n {\n \"key\": \"maxCurrent\",\n \"label\": \"Max Current\",\n \"type\": \"number\",\n \"unit\": \"A\",\n \"default\": 1,\n \"min\": 0\n }\n ],\n \"svgPath\": \"M10,15 h10 M20,5 l0,20 L40,15 L20,5 M40,5 v20 M40,15 h10\"\n}\n","{\n \"id\": \"transistor-npn\",\n \"name\": \"NPN Transistor\",\n \"category\": \"semiconductor\",\n \"description\": \"An NPN bipolar junction transistor for amplification and switching\",\n \"defaultWidth\": 50,\n \"defaultHeight\": 60,\n \"ports\": [\n {\n \"id\": \"collector\",\n \"x\": 25,\n \"y\": 0,\n \"type\": \"inout\"\n },\n {\n \"id\": \"base\",\n \"x\": 0,\n \"y\": 30,\n \"type\": \"input\"\n },\n {\n \"id\": \"emitter\",\n \"x\": 25,\n \"y\": 60,\n \"type\": \"inout\"\n }\n ],\n \"properties\": [\n {\n \"key\": \"gain\",\n \"label\": \"Current Gain (β)\",\n \"type\": \"number\",\n \"default\": 100,\n \"min\": 1\n }\n ],\n \"svgPath\": \"M25,0 v15 M25,45 v15 M0,30 h15 M15,15 v30 M15,30 l20,-10 l0,20 Z\"\n}\n","{\n \"id\": \"led\",\n \"name\": \"LED\",\n \"category\": \"semiconductor\",\n \"description\": \"Light-emitting diode that emits light when current flows through it\",\n \"defaultWidth\": 60,\n \"defaultHeight\": 40,\n \"ports\": [\n {\n \"id\": \"anode\",\n \"x\": 0,\n \"y\": 20,\n \"type\": \"inout\"\n },\n {\n \"id\": \"cathode\",\n \"x\": 60,\n \"y\": 20,\n \"type\": \"inout\"\n }\n ],\n \"properties\": [\n {\n \"key\": \"color\",\n \"label\": \"Color\",\n \"type\": \"color\",\n \"default\": \"#ff0000\"\n },\n {\n \"key\": \"forwardVoltage\",\n \"label\": \"Forward Voltage\",\n \"type\": \"number\",\n \"unit\": \"V\",\n \"default\": 1.8,\n \"min\": 0\n },\n {\n \"key\": \"maxCurrent\",\n \"label\": \"Max Current\",\n \"type\": \"number\",\n \"unit\": \"mA\",\n \"default\": 20,\n \"min\": 0\n }\n ],\n \"svgPath\": \"M10,20 h10 M20,5 l0,30 L40,20 L20,5 M40,5 v30 M40,20 h10 M25,5 l5,-5 l5,10 l5,-5 M25,10 l5,-5 l5,10 l5,-5\"\n}\n","{\n \"id\": \"ic\",\n \"name\": \"Integrated Circuit\",\n \"category\": \"advanced\",\n \"description\": \"A general-purpose integrated circuit with configurable pins\",\n \"defaultWidth\": 80,\n \"defaultHeight\": 100,\n \"ports\": [\n {\n \"id\": \"vcc\",\n \"x\": 20,\n \"y\": 0,\n \"type\": \"input\",\n \"label\": \"VCC\"\n },\n {\n \"id\": \"gnd\",\n \"x\": 60,\n \"y\": 100,\n \"type\": \"input\",\n \"label\": \"GND\"\n },\n {\n \"id\": \"in1\",\n \"x\": 0,\n \"y\": 20,\n \"type\": \"input\",\n \"label\": \"IN1\"\n },\n {\n \"id\": \"in2\",\n \"x\": 0,\n \"y\": 40,\n \"type\": \"input\",\n \"label\": \"IN2\"\n },\n {\n \"id\": \"in3\",\n \"x\": 0,\n \"y\": 60,\n \"type\": \"input\",\n \"label\": \"IN3\"\n },\n {\n \"id\": \"in4\",\n \"x\": 0,\n \"y\": 80,\n \"type\": \"input\",\n \"label\": \"IN4\"\n },\n {\n \"id\": \"out1\",\n \"x\": 80,\n \"y\": 20,\n \"type\": \"output\",\n \"label\": \"OUT1\"\n },\n {\n \"id\": \"out2\",\n \"x\": 80,\n \"y\": 40,\n \"type\": \"output\",\n \"label\": \"OUT2\"\n },\n {\n \"id\": \"out3\",\n \"x\": 80,\n \"y\": 60,\n \"type\": \"output\",\n \"label\": \"OUT3\"\n },\n {\n \"id\": \"out4\",\n \"x\": 80,\n \"y\": 80,\n \"type\": \"output\",\n \"label\": \"OUT4\"\n }\n ],\n \"properties\": [\n {\n \"key\": \"name\",\n \"label\": \"IC Name\",\n \"type\": \"text\",\n \"default\": \"IC1\"\n },\n {\n \"key\": \"type\",\n \"label\": \"IC Type\",\n \"type\": \"text\",\n \"default\": \"Custom IC\"\n }\n ],\n \"svgPath\": \"<svg width='80' height='100'><rect x='10' y='10' width='60' height='80' fill='none' stroke='black' stroke-width='2'/><text x='40' y='50' text-anchor='middle' font-family='monospace' font-size='12'>IC</text><circle cx='20' cy='10' r='3' fill='none' stroke='black'/></svg>\"\n}\n","/**\n * Component registry loader\n * \n * This module imports and registers all component schemas\n */\n\nimport { registerComponent } from '../index';\nimport { ComponentSchema } from '../../types';\n\n// Import component schemas\nimport resistorSchema from './resistor.json';\nimport capacitorSchema from './capacitor.json';\nimport batterySchema from './battery.json';\nimport groundSchema from './ground.json';\nimport switchSchema from './switch.json';\nimport voltageSourceSchema from './voltage-source.json';\nimport diodeSchema from './diode.json';\nimport transistorNpnSchema from './transistor-npn.json';\nimport ledSchema from './led.json';\nimport icSchema from './ic.json';\n\n// Register components\nconst registerBuiltInComponents = () => {\n // Basic components\n registerComponent(resistorSchema as ComponentSchema);\n registerComponent(capacitorSchema as ComponentSchema);\n registerComponent(groundSchema as ComponentSchema);\n registerComponent(switchSchema as ComponentSchema);\n \n // Sources\n registerComponent(batterySchema as ComponentSchema);\n registerComponent(voltageSourceSchema as ComponentSchema);\n \n // Semiconductors\n registerComponent(diodeSchema as ComponentSchema);\n registerComponent(transistorNpnSchema as ComponentSchema);\n registerComponent(ledSchema as ComponentSchema);\n \n // Advanced components\n registerComponent(icSchema as ComponentSchema);\n \n // Add more component registrations here as they are created\n};\n\nexport default registerBuiltInComponents;\n","/**\n * Registry module for component schemas\n * \n * This module provides utilities for registering, retrieving, and managing component schemas.\n * The component registry is a central repository of all available component types that can\n * be used in circuits. Each component is defined by a schema that specifies its appearance,\n * ports, and configurable properties.\n * \n * @module Registry\n */\n\nimport { ComponentSchema } from '../types';\nimport registerBuiltInComponents from './components';\n\n// Internal registry storage\nconst componentRegistry: Record<string, ComponentSchema> = {};\n\n/**\n * Register a component schema in the registry\n * \n * This function adds a new component schema to the registry or updates an existing one.\n * Custom components can be registered to extend the library with new circuit elements.\n * \n * @param {ComponentSchema} schema - The component schema to register\n * \n * @example\n * // Register a custom LED component\n * registerComponent({\n * id: 'custom-led',\n * name: 'Custom LED',\n * category: 'output',\n * description: 'A light-emitting diode with customizable color',\n * defaultWidth: 30,\n * defaultHeight: 20,\n * ports: [\n * { id: 'anode', x: 0, y: 10, type: 'input' },\n * { id: 'cathode', x: 30, y: 10, type: 'output' }\n * ],\n * properties: [\n * { id: 'color', name: 'Color', type: 'color', default: '#ff0000' }\n * ],\n * svgPath: `<circle cx=\"15\" cy=\"10\" r=\"8\" fill=\"currentColor\" />`\n * });\n */\nexport function registerComponent(schema: ComponentSchema): void {\n if (componentRegistry[schema.id]) {\n console.warn(`Component with ID ${schema.id} already exists in registry. Overwriting.`);\n }\n componentRegistry[schema.id] = schema;\n}\n\n/**\n * Get a component schema from the registry by ID\n * \n * Retrieves a component's schema definition by its unique identifier.\n * Returns undefined if no component with the specified ID exists.\n * \n * @param {string} id - The unique identifier of the component\n * @returns {ComponentSchema | undefined} The component schema or undefined if not found\n * \n * @example\n * const resistorSchema = getComponentSchema('resistor');\n * if (resistorSchema) {\n * console.log(`Resistor has ${resistorSchema.ports.length} ports`);\n * }\n */\nexport function getComponentSchema(id: string): ComponentSchema | undefined {\n return componentRegistry[id];\n}\n\n/**\n * Get all component schemas in the registry\n */\nexport function getAllComponents(): ComponentSchema[] {\n return Object.values(componentRegistry);\n}\n\n/**\n * Get component schemas by category\n */\nexport function getComponentsByCategory(category: string): ComponentSchema[] {\n return Object.values(componentRegistry)\n .filter(schema => schema.category === category);\n}\n\n// Register built-in components\nregisterBuiltInComponents();\n\nexport default componentRegistry;\n","/**\n * Port Component\n * \n * Renders a connection port on a circuit component\n */\n\nimport React from 'react';\nimport { PortSchema } from '../types';\n\nexport interface PortProps {\n componentId: string;\n port: PortSchema;\n selected?: boolean;\n onClick?: (portId: string, event: React.MouseEvent) => void;\n}\n\nconst Port: React.FC<PortProps> = ({\n componentId,\n port,\n selected = false,\n onClick\n}) => {\n const handleClick = (event: React.MouseEvent) => {\n event.stopPropagation(); // Prevent triggering component click\n onClick?.(port.id, event);\n };\n\n return (\n <circle\n cx={port.x}\n cy={port.y}\n r={5}\n fill={selected ? '#4f8ef7' : '#555'}\n stroke=\"#000\"\n strokeWidth={1}\n data-component-id={componentId}\n data-port-id={port.id}\n data-port-type={port.type}\n vectorEffect=\"non-scaling-stroke\"\n onClick={handleClick}\n style={{ cursor: 'pointer' }}\n />\n );\n};\n\nexport default Port;\n","/**\n * BaseComponent\n * \n * A core component that renders SVG for a circuit component based on its schema.\n */\n\nimport React from 'react';\nimport { ComponentSchema, ComponentInstance } from '../types';\nimport Port from './Port';\n\nexport interface BaseComponentProps {\n schema: ComponentSchema;\n component: ComponentInstance;\n onClick?: (event: React.MouseEvent) => void;\n onMouseDown?: (event: React.MouseEvent) => void;\n onPortClick?: (componentId: string, portId: string, event: React.MouseEvent) => void;\n selected?: boolean;\n}\n\nconst BaseComponent: React.FC<BaseComponentProps> = ({\n schema,\n component,\n onClick,\n onMouseDown,\n onPortClick,\n selected = false\n}) => {\n const { id, position, size, rotation = 0 } = component;\n \n // Determine the size to use - from the component instance or defaults\n const width = size?.width || schema.defaultWidth;\n const height = size?.height || schema.defaultHeight;\n\n // Create a transform for position and optional rotation\n const transform = `translate(${position.x}, ${position.y})${\n rotation ? ` rotate(${rotation} ${width / 2} ${height / 2})` : ''\n }`;\n\n // Style for the selection outline\n const outlineStyle = selected\n ? {\n stroke: '#4f8ef7',\n strokeWidth: 2,\n strokeDasharray: 'none',\n fill: 'none',\n pointerEvents: 'none' as const,\n vectorEffect: 'non-scaling-stroke' as const,\n }\n : undefined;\n\n return (\n <g\n transform={transform}\n onClick={onClick}\n onMouseDown={onMouseDown}\n data-component-id={id}\n data-component-type={component.type}\n >\n {/* SVG content from the schema */}\n <svg\n width={width}\n height={height}\n viewBox={`0 0 ${schema.defaultWidth} ${schema.defaultHeight}`}\n preserveAspectRatio=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n {/* If svgPath is a complete SVG element with tags */}\n {schema.svgPath.startsWith('<svg') ? (\n <g dangerouslySetInnerHTML={{ __html: schema.svgPath }} />\n ) : (\n // Otherwise treat it as a path data string\n <path\n d={schema.svgPath}\n fill=\"none\"\n stroke=\"black\"\n strokeWidth={2}\n vectorEffect=\"non-scaling-stroke\"\n />\n )}\n </svg>\n\n {/* Render ports */}\n {schema.ports.map(port => (\n <Port\n key={port.id}\n componentId={id}\n port={port}\n selected={false}\n onClick={(portId, event) => onPortClick?.(id, portId, event)}\n />\n ))}\n\n {/* Selection outline */}\n {selected && (\n <rect\n x={0}\n y={0}\n width={width}\n height={height}\n {...outlineStyle}\n />\n )}\n </g>\n );\n};\n\nexport default BaseComponent;\n","/**\n * Brick Component\n * \n * A wrapper around BaseComponent that handles schema lookup and error states.\n */\n\nimport React from 'react';\nimport { ComponentInstance } from '../types';\nimport { getComponentSchema } from '../registry';\nimport BaseComponent from './BaseComponent';\n\nexport interface BrickProps {\n component: ComponentInstance;\n onClick?: (event: React.MouseEvent) => void;\n onMouseDown?: (event: React.MouseEvent) => void;\n onPortClick?: (componentId: string, portId: string, event: React.MouseEvent) => void;\n selected?: boolean;\n}\n\nconst Brick: React.FC<BrickProps> = ({\n component,\n onClick,\n onMouseDown,\n onPortClick,\n selected = false\n}) => {\n const schema = getComponentSchema(component.type);\n \n // Handle missing schema with a placeholder component\n if (!schema) {\n console.warn(`No schema found for component type: ${component.type}`);\n return (\n <g \n transform={`translate(${component.position.x}, ${component.position.y})`}\n onClick={onClick}\n onMouseDown={onMouseDown}\n data-component-id={component.id}\n data-component-type=\"unknown\"\n >\n <rect \n width={100} \n height={50} \n fill=\"red\" \n stroke=\"black\" \n strokeWidth={1.5}\n strokeDasharray=\"5,5\"\n vectorEffect=\"non-scaling-stroke\"\n />\n <text \n x={50} \n y={25} \n textAnchor=\"middle\" \n dominantBaseline=\"middle\" \n fill=\"white\"\n fontFamily=\"sans-serif\"\n fontSize=\"12px\"\n >\n Unknown: {component.type}\n </text>\n </g>\n );\n }\n \n // Render the component with its schema\n return (\n <BaseComponent \n schema={schema}\n component={component}\n onClick={onClick}\n onMouseDown={onMouseDown}\n onPortClick={onPortClick}\n selected={selected}\n />\n );\n};\n\nexport default Brick;\n","/**\n * Port position utility\n * \n * Provides functions to get the absolute position of ports in the DOM.\n * Compatible with infinite circuit canvas with panning and zooming.\n */\n\nimport { Point } from '../types';\n\n/**\n * Retrieves the absolute position of a port in the SVG coordinate system,\n * accounting for any viewport transformations (pan/zoom).\n * \n * @param componentId - The ID of the component\n * @param portId - The ID of the port on the component\n * @returns The position of the port or null if not found\n */\nexport function getPortPosition(componentId: string, portId: string): Point | null {\n // Find the port element in the DOM\n const portElement = document.querySelector(\n `[data-component-id=\"${componentId}\"][data-port-id=\"${portId}\"]`\n );\n\n if (!portElement) {\n console.warn(`Port not found: componentId=${componentId}, portId=${portId}`);\n return null;\n }\n\n // Find the SVG root to calculate the relative position\n let currentEl: Element | null = portElement;\n let svgRoot: SVGSVGElement | null = null;\n \n while (currentEl) {\n if (currentEl instanceof SVGSVGElement) {\n svgRoot = currentEl;\n break;\n }\n currentEl = currentEl.parentElement;\n }\n \n if (!svgRoot) {\n console.warn('SVG root not found for port');\n return null;\n }\n \n // Get the bounding client rect of the port element and SVG\n const portRect = (portElement as SVGElement).getBoundingClientRect();\n const svgRect = svgRoot.getBoundingClientRect();\n \n // Get the center point of the port in client coordinates\n const clientX = portRect.left + portRect.width / 2;\n const clientY = portRect.top + portRect.height / 2;\n \n // Create an SVG point in client coordinates\n const svgPoint = svgRoot.createSVGPoint();\n svgPoint.x = clientX;\n svgPoint.y = clientY;\n \n // Get the current transformation matrix and its inverse\n // This will automatically factor in any viewport transformations (pan/zoom)\n const ctm = svgRoot.getScreenCTM();\n if (!ctm) {\n console.warn('Could not get screen CTM for SVG');\n return null;\n }\n \n const inverseCTM = ctm.inverse();\n \n // Transform the client coordinates to SVG user space\n const transformedPoint = svgPoint.matrixTransform(inverseCTM);\n \n // Return the point in user space coordinates, which accounts for pan/zoom\n return {\n x: transformedPoint.x,\n y: transformedPoint.y\n };\n}\n\n/**\n * Hook version of getPortPosition for React components\n * Will be implemented in a separate usePortPosition.ts file\n */\n","/**\n * WirePath Component\n * \n * Renders a wire connection between two component ports.\n */\n\nimport React, { useEffect, useState, useRef } from 'react';\nimport { Wire, ComponentInstance, Point } from '../types';\nimport { getPortPosition } from '../utils/getPortPosition';\n\nexport interface WirePathProps {\n wire: Wire;\n components: ComponentInstance[];\n selected?: boolean;\n onClick?: (event: React.MouseEvent) => void;\n}\n\ninterface PortPosition {\n x: number;\n y: number;\n found: boolean;\n}\n\nconst WirePath: React.FC<WirePathProps> = ({\n wire,\n components,\n selected = false,\n onClick\n}) => {\n const [fromPos, setFromPos] = useState<PortPosition>({ x: 0, y: 0, found: false });\n const [toPos, setToPos] = useState<PortPosition>({ x: 0, y: 0, found: false });\n const pathRef = useRef<SVGPathElement>(null);\n\n // Function to update port positions\n const updatePortPositions = () => {\n try {\n const fromPosition = getPortPosition(wire.from.componentId, wire.from.portId);\n if (fromPosition) {\n setFromPos({ ...fromPosition, found: true });\n }\n } catch (error) {\n console.warn(`Could not find source port for wire ${wire.id}:`, error);\n }\n\n try {\n const toPosition = getPortPosition(wire.to.componentId, wire.to.portId);\n if (toPosition) {\n setToPos({ ...toPosition, found: true });\n }\n } catch (error) {\n console.warn(`Could not find destination port for wire ${wire.id}:`, error);\n }\n };\n\n // Setup a MutationObserver to watch for component position changes\n useEffect(() => {\n updatePortPositions();\n \n // Watch for changes in component attributes (like x, y, transform)\n const observer = new MutationObserver((mutations) => {\n let shouldUpdate = false;\n \n mutations.forEach((mutation) => {\n if (mutation.type === 'attributes' && \n (mutation.attributeName === 'transform' || \n mutation.attributeName === 'x' || \n mutation.attributeName === 'y')) {\n shouldUpdate = true;\n }\n });\n \n if (shouldUpdate) {\n updatePortPositions();\n }\n });\n \n // Find all relevant component SVG elements to observe\n const fromComponent = document.querySelector(`[data-component-id=\"${wire.from.componentId}\"]`);\n const toComponent = document.querySelector(`[data-component-id=\"${wire.to.componentId}\"]`);\n \n if (fromComponent) {\n observer.observe(fromComponent, { attributes: true, attributeFilter: ['transform', 'x', 'y'] });\n }\n \n if (toComponent) {\n observer.observe(toComponent, { attributes: true, attributeFilter: ['transform', 'x', 'y'] });\n }\n \n // Also set up a resize observer to update positions when the canvas resizes\n const resizeObserver = new ResizeObserver(() => {\n updatePortPositions();\n });\n \n if (pathRef.current) {\n const svgElement = pathRef.current.closest('svg');\n if (svgElement) {\n resizeObserver.observe(svgElement);\n }\n }\n \n return () => {\n observer.disconnect();\n resizeObserver.disconnect();\n };\n }, [wire, components]);\n\n // Update positions when components array changes (new components added or removed)\n useEffect(() => {\n updatePortPositions();\n }, [components.length]);\n\n // Do not render the wire if either port was not found\n if (!fromPos.found || !toPos.found) {\n return null;\n }\n\n // Calculate a bezier curve path with proper spacing between components\n const generatePath = (from: Point, to: Point): string => {\n // Calculate distance between points\n const dx = to.x - from.x;\n const dy = to.y - from.y;\n const distance = Math.sqrt(dx * dx + dy * dy);\n \n // For very short distances, use a straight line\n if (distance < 20) {\n return `M ${from.x} ${from.y} L ${to.x} ${to.y}`;\n }\n \n // Calculate control points with optimized offset\n const controlPointLength = Math.min(distance / 3, 60); // Reduced for smoother curves\n \n // Determine dominant axis and adjust control points to create natural curves\n if (Math.abs(dx) > Math.abs(dy)) {\n // Horizontal dominant\n const cp1x = from.x + controlPointLength;\n const cp1y = from.y;\n const cp2x = to.x - controlPointLength;\n const cp2y = to.y;\n return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}`;\n } else {\n // Vertical dominant\n const cp1x = from.x;\n const cp1y = from.y + controlPointLength;\n const cp2x = to.x;\n const cp2y = to.y - controlPointLength;\n return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}`;\n }\n };\n\n // Wire style based on selected state and custom style\n const wireStyle = {\n stroke: selected ? '#4f8ef7' : (wire.style?.color || '#333'),\n strokeWidth: selected ? 3 : (wire.style?.strokeWidth || 2),\n strokeDasharray: wire.style?.dashArray || 'none',\n fill: 'none',\n vectorEffect: 'non-scaling-stroke' as const,\n };\n\n return (\n <path\n ref={pathRef}\n d={generatePath(fromPos, toPos)}\n className={`wire ${selected ? 'selected' : ''}`}\n {...wireStyle}\n onClick={onClick}\n data-wire-id={wire.id}\n data-from-component={wire.from.componentId}\n data-from-port={wire.from.portId}\n data-to-component={wire.to.componentId}\n data-to-port={wire.to.portId}\n style={{ cursor: 'pointer' }}\n />\n );\n};\n\nexport default WirePath;\n","/**\n * CircuitCanvas Component\n * \n * The main canvas component that renders circuit components and wires.\n * Supports infinite panning and zooming for a free-flowing design experience.\n * \n * @component\n * @example\n * <CircuitCanvas\n * components={state.components}\n * wires={state.wires}\n * width={800}\n * height={600}\n * onComponentClick={handleComponentClick}\n * showGrid={true}\n * />\n */\n\nimport React, { useRef, useState, useEffect, useCallback } from 'react';\nimport { ComponentInstance, Wire, Point } from '../types';\nimport Brick from './Brick';\nimport WirePath from './WirePath';\nimport { getPortPosition } from '../utils/getPortPosition';\nimport { getComponentSchema } from '../registry';\n\nexport interface CircuitCanvasProps {\n /** Array of component instances to render on the canvas */\n components: ComponentInstance[];\n \n /** Array of wires connecting components */\n wires: Wire[];\n \n /** Width of the canvas. Can be a number (pixels) or string (e.g., '100%') */\n width?: number | string;\n \n /** Height of the canvas. Can be a number (pixels) or string (e.g., '100%') */\n height?: number | string;\n \n /** Callback when a component is clicked */\n onComponentClick?: (id: string, event: React.MouseEvent) => void;\n \n /** Callback when a wire is clicked */\n onWireClick?: (id: string, event: React.MouseEvent) => void;\n \n /** Callback when the canvas background is clicked */\n onCanvasClick?: (event: React.MouseEvent) => void;\n \n /** Callback when a component is dragged to a new position */\n onComponentDrag?: (id: string, newPosition: Point) => void;\n \n /** Callback when wire drawing starts from a port */\n onWireDrawStart?: (componentId: string, portId: string) => void;\n \n /** Callback when wire drawing ends on a port. Return true to accept the connection. */\n onWireDrawEnd?: (componentId: string, portId: string) => boolean;\n \n /** Callback when wire drawing is canceled */\n onWireDrawCancel?: () => void;\n \n /** Object representing the current wire drawing state */\n wireDrawing?: {\n isDrawing: boolean;\n fromComponentId: string | null;\n fromPortId: string | null;\n };\n \n /** Array of currently selected component IDs */\n selectedComponentIds?: string[];\n \n /** Array of currently selected wire IDs */\n selectedWireIds?: string[];\n \n /** Whether to show the grid on the canvas */\n showGrid?: boolean;\n \n /** Size of the grid cells in pixels */\n gridSize?: number;\n \n /** Whether to snap components to the grid */\n snapToGrid?: boolean;\n \n /** Callback when a component is dropped onto the canvas */\n onComponentDrop?: (componentType: string, position: Point) => void;\n \n /** Initial zoom level for the canvas (1.0 = 100%) */\n initialZoom?: number;\n \n /** Minimum allowed zoom level */\n minZoom?: number;\n \n /** Maximum allowed zoom level */\n maxZoom?: number;\n}\n\nexport const CircuitCanvas: React.FC<CircuitCanvasProps> = ({\n components,\n wires,\n width = '100%',\n height = '100%',\n onComponentClick,\n onWireClick,\n onCanvasClick,\n onComponentDrag,\n onWireDrawStart,\n onWireDrawEnd,\n onWireDrawCancel,\n wireDrawing = {\n isDrawing: false,\n fromComponentId: null,\n fromPortId: null\n },\n selectedComponentIds = [],\n selectedWireIds = [],\n showGrid = true,\n gridSize = 20,\n snapToGrid = false,\n onComponentDrop,\n initialZoom = 1.0,\n minZoom = 0.25,\n maxZoom = 3.0\n}) => {\n const svgRef = useRef<SVGSVGElement>(null);\n const [dragState, setDragState] = useState<{\n isDragging: boolean;\n componentId: string | null;\n startPos: Point | null;\n currentPos: Point | null;\n }>({\n isDragging: false,\n componentId: null,\n startPos: null,\n currentPos: null\n });\n \n const [currentMousePos, setCurrentMousePos] = useState<Point | null>(null);\n const [sourcePortPos, setSourcePortPos] = useState<Point | null>(null);\n \n // Viewport state for infinite canvas\n const [viewportTransform, setViewportTransform] = useState({\n x: 0,\n y: 0,\n scale: initialZoom\n });\n \n // For canvas panning\n const [isPanning, setIsPanning] = useState(false);\n const [panStartPos, setPanStartPos] = useState<Point | null>(null);\n \n // Ref to detect if mouse is down (for panning)\n const isMouseDownRef = useRef(false);\n\n // Effect to dispatch viewport-change event when transform changes\n useEffect(() => {\n if (!svgRef.current) return;\n \n // Create a custom event for viewport changes\n const viewportChangeEvent = new CustomEvent('viewport-change', {\n detail: viewportTransform,\n bubbles: true\n });\n \n // Dispatch the event\n svgRef.current.dispatchEvent(viewportChangeEvent);\n }, [viewportTransform]);\n\n // Effect to update the source port position when wire drawing starts\n useEffect(() => {\n if (wireDrawing.isDrawing && wireDrawing.fromComponentId && wireDrawing.fromPortId) {\n // Schedule a brief timeout to allow the DOM to update\n const timer = setTimeout(() => {\n const portPos = getPortPosition(wireDrawing.fromComponentId!, wireDrawing.fromPortId!);\n if (portPos) {\n setSourcePortPos(portPos);\n }\n }, 0);\n \n return () => clearTimeout(timer);\n } else {\n setSourcePortPos(null);\n }\n }, [wireDrawing]);\n\n // Convert screen coordinates to SVG coordinates (accounting for pan and zoom)\n const screenToSvgCoordinates = useCallback((clientX: number, clientY: number): Point => {\n if (!svgRef.current) return { x: 0, y: 0 };\n \n const svgRect = svgRef.current.getBoundingClientRect();\n const x = (clientX - svgRect.left - viewportTransform.x) / viewportTransform.scale;\n const y = (clientY - svgRect.top - viewportTransform.y) / viewportTransform.scale;\n \n // Snap to grid if enabled\n if (snapToGrid) {\n return { \n x: Math.round(x / gridSize) * gridSize,\n y: Math.round(y / gridSize) * gridSize\n };\n }\n \n return { x, y };\n }, [gridSize, snapToGrid, viewportTransform]);\n\n // Get SVG coordinates from mouse event (accounting for pan and zoom)\n const getSvgCoordinates = useCallback((e: React.MouseEvent | React.DragEvent): Point => {\n return screenToSvgCoordinates(e.clientX, e.clientY);\n }, [screenToSvgCoordinates]);\n\n const handleCanvasClick = (e: React.MouseEvent) => {\n // Only trigger if the click is directly on the canvas\n if (e.target === e.currentTarget) {\n // If we weren't panning, trigger the canvas click\n if (!isPanning) {\n onCanvasClick?.(e);\n \n // If we're drawing a wire, cancel it\n if (wireDrawing.isDrawing) {\n onWireDrawCancel?.();\n }\n }\n }\n };\n\n const handleComponentClick = (id: string, event: React.MouseEvent) => {\n // Only handle click if we're not dragging\n if (!dragState.isDragging && !isPanning) {\n onComponentClick?.(id, event);\n }\n };\n\n const handleWireClick = (id: string, event: React.MouseEvent) => {\n if (!isPanning) {\n onWireClick?.(id, event);\n }\n };\n\n const handleMouseDown = (componentId: string, event: React.MouseEvent) => {\n // Start dragging - allow dragging of any clicked component\n const startPos = getSvgCoordinates(event);\n setDragState({\n isDragging: true,\n componentId,\n startPos,\n currentPos: startPos\n });\n \n // Ensure the component is selected\n if (!selectedComponentIds.includes(componentId) && onComponentClick) {\n onComponentClick(componentId, event);\n }\n \n event.stopPropagation();\n };\n\n // Handle mouse down on the canvas for panning\n const handleCanvasMouseDown = (event: React.MouseEvent) => {\n // Only start panning if we click the canvas background and it's a middle mouse button or space + left click\n if (event.target === event.currentTarget && (event.button === 1 || (event.button === 0 && event.altKey))) {\n setIsPanning(true);\n setPanStartPos({ x: event.clientX, y: event.clientY });\n isMouseDownRef.current = true;\n \n // Prevent default to avoid text selection\n event.preventDefault();\n }\n };\n\n const handleMouseMove = (event: React.MouseEvent) => {\n // Handle pan movement\n if (isPanning && panStartPos) {\n const dx = event.clientX - panStartPos.x;\n const dy = event.clientY - panStartPos.y;\n \n setViewportTransform(prev => ({\n ...prev,\n x: prev.x + dx,\n y: prev.y + dy\n }));\n \n setPanStartPos({ x: event.clientX, y: event.clientY });\n return;\n }\n \n // For regular interaction, convert coordinates\n const currentPos = getSvgCoordinates(event);\n setCurrentMousePos(currentPos);\n \n // Update drag position if dragging\n if (dragState.isDragging && dragState.componentId) {\n setDragState({ ...dragState, currentPos });\n \n // Find the component being dragged\n const component = components.find(c => c.id === dragState.componentId);\n if (component && dragState.startPos && currentPos) {\n // Calculate the new position\n const dx = currentPos.x - dragState.startPos.x;\n const dy = currentPos.y - dragState.startPos.y;\n const newPosition = {\n x: component.position.x + dx,\n y: component.position.y + dy\n };\n \n // Snap to grid if needed (now controlled by snapToGrid prop)\n if (snapToGrid) {\n newPosition.x = Math.round(newPosition.x / gridSize) * gridSize;\n newPosition.y = Math.round(newPosition.y / gridSize) * gridSize;\n }\n \n // Call the onComponentDrag handler\n onComponentDrag?.(dragState.componentId, newPosition);\n }\n }\n };\n\n const handleMouseUp = (event: React.MouseEvent) => {\n // End dragging\n if (dragState.isDragging) {\n setDragState({\n isDragging: false,\n componentId: null,\n startPos: null,\n currentPos: null\n });\n }\n \n // End panning\n if (isPanning) {\n setIsPanning(false);\n setPanStartPos(null);\n }\n \n isMouseDownRef.current = false;\n };\n \n // Handle mouse leaving the canvas\n const handleMouseLeave = () => {\n if (isPanning) {\n setIsPanning(false);\n setPanStartPos(null);\n }\n isMouseDownRef.current = false;\n };\n \n // Handle wheel event for zooming\n const handleWheel = (event: React.WheelEvent) => {\n event.preventDefault();\n \n // Only zoom if ctrl key is pressed\n if (event.ctrlKey || event.metaKey) {\n const delta = event.deltaY > 0 ? -0.1 : 0.1;\n const newScale = Math.max(minZoom, Math.min(maxZoom, viewportTransform.scale + delta));\n \n // Get mouse position relative to SVG\n const svgRect = svgRef.current?.getBoundingClientRect();\n if (!svgRect) return;\n \n const mouseX = event.clientX - svgRect.left;\n const mouseY = event.clientY - svgRect.top;\n \n // Calculate new transform origin based on mouse position\n setViewportTransform(prev => {\n // Calculate the point in world coordinates\n const worldX = (mouseX - prev.x) / prev.scale;\n const worldY = (mouseY - prev.y) / prev.scale;\n \n // Calculate new transform (zoom around mouse position)\n const newX = mouseX - worldX * newScale;\n const newY = mouseY - worldY * newScale;\n \n return {\n x: newX,\n y: newY,\n scale: newScale\n };\n });\n } else {\n // Pan horizontally with shift+wheel\n if (event.shiftKey) {\n setViewportTransform(prev => ({\n ...prev,\n x: prev.x - event.deltaY\n }));\n } else {\n // Pan vertically with wheel\n setViewportTransform(prev => ({\n ...prev,\n y: prev.y - event.deltaY\n }));\n }\n }\n };\n\n const handlePortClick = (componentId: string, portId: string, event: React.MouseEvent) => {\n // Ignore port clicks when panning\n if (isPanning) return;\n \n event.stopPropagation();\n \n // If we're not drawing a wire, start drawing\n if (!wireDrawing.isDrawing) {\n onWireDrawStart?.(componentId, portId);\n } else {\n // If we are drawing a wire, end drawing and connect to this port\n if (wireDrawing.fromComponentId && wireDrawing.fromPortId) {\n onWireDrawEnd?.(componentId, portId);\n }\n }\n };\n\n // Generate a bezier curve path between two points - updated to match WirePath\n const generateWirePath = (from: Point, to: Point): string => {\n // Calculate distance between points\n const dx = to.x - from.x;\n const dy = to.y - from.y;\n const distance = Math.sqrt(dx * dx + dy * dy);\n \n // For very short distances, use a straight line\n if (distance < 20) {\n return `M ${from.x} ${from.y} L ${to.x} ${to.y}`;\n }\n \n // Calculate control points with optimized offset\n const controlPointLength = Math.min(distance / 3, 60); // Reduced for smoother curves\n \n // Determine dominant axis and adjust control points to create natural curves\n if (Math.abs(dx) > Math.abs(dy)) {\n // Horizontal dominant\n const cp1x = from.x + controlPointLength;\n const cp1y = from.y;\n const cp2x = to.x - controlPointLength;\n const cp2y = to.y;\n return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}`;\n } else {\n // Vertical dominant\n const cp1x = from.x;\n const cp1y = from.y + controlPointLength;\n const cp2x = to.x;\n const cp2y = to.y - controlPointLength;\n return `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${to.x} ${to.y}`;\n }\n };\n \n // Handle drag and drop events for component palette\n const handleDragOver = (event: React.DragEvent) => {\n // Prevent default to allow drop\n event.preventDefault();\n // Set the drop effect to copy\n event.dataTransfer.dropEffect = 'copy';\n };\n \n const handleDrop = (event: React.DragEvent) => {\n event.preventDefault();\n \n // Get the component type from the drag data\n const componentType = event.dataTransfer.getData('application/circuit-component');\n if (!componentType || !onComponentDrop) return;\n \n // Get drop position in SVG coordinates (taking into account pan/zoom)\n const position = getSvgCoordinates(event);\n \n // Create a new component at this position\n onComponentDrop(componentType, position);\n };\n\n // Create keyboard shortcut handlers for common operations\n useEffect(() => {\n const handleKeyDown = (e: KeyboardEvent) => {\n // Only process when the canvas is active\n if (!svgRef.current) return;\n \n // Ignore if we're in an input field\n if (e.target instanceof HTMLInputElement || \n e.target instanceof HTMLTextAreaElement) {\n return;\n }\n \n // Handle space key for panning toggle\n if (e.code === 'Space' && !isPanning && document.activeElement === document.body) {\n e.preventDefault(); // Prevent page scroll\n }\n \n // Reset view with key '0'\n if (e.key === '0' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault();\n setViewportTransform({\n x: 0,\n y: 0,\n scale: 1.0\n });\n }\n \n // Zoom in with + or =\n if ((e.key === '+' || e.key === '=') && (e.ctrlKey || e.metaKey)) {\n e.preventDefault();\n setViewportTransform(prev => ({\n ...prev,\n scale: Math.min(maxZoom, prev.scale + 0.1)\n }));\n }\n \n // Zoom out with -\n if (e.key === '-' && (e.ctrlKey || e.metaKey)) {\n e.preventDefault();\n setViewportTransform(prev => ({\n ...prev,\n scale: Math.max(minZoom, prev.scale - 0.1)\n }));\n }\n \n // Arrow keys for panning when Alt is pressed\n if (e.altKey && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {\n e.preventDefault();\n const panAmount = e.shiftKey ? 50 : 20; // Faster panning with Shift\n \n setViewportTransform(prev => {\n let dx = 0, dy = 0;\n if (e.key === 'ArrowLeft') dx = panAmount;\n if (e.key === 'ArrowRight') dx = -panAmount;\n if (e.key === 'ArrowUp') dy = panAmount;\n if (e.key === 'ArrowDown') dy = -panAmount;\n \n return {\n ...prev,\n x: prev.x + dx,\n y: prev.y + dy\n };\n });\n }\n };\n \n window.addEventListener('keydown', handleKeyDown);\n return () => {\n window.removeEventListener('keydown', handleKeyDown);\n };\n }, [isPanning, maxZoom, minZoom]);\n\n // State for showing help message\n const [showHelp, setShowHelp] = useState(true);\n \n // Hide help message after 15 seconds\n useEffect(() => {\n if (showHelp) {\n const timer = setTimeou