diff --git a/dashboard/index.html b/dashboard/index.html index ee89250..9bea2eb 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -2428,6 +2428,38 @@ .tuning-btn.reset:hover { background: rgba(255, 255, 255, 0.15); } + + /* Fresnel zone debug tooltip */ + .fresnel-tooltip { + position: fixed; + display: none; + background: rgba(0, 0, 0, 0.9); + border: 1px solid rgba(79, 195, 247, 0.5); + border-radius: 6px; + padding: 10px 12px; + font-size: 12px; + color: #eee; + pointer-events: none; + z-index: 1000; + max-width: 280px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + } + .fresnel-tooltip strong { + color: #4fc3f7; + } + + /* Fresnel ellipsoid hover effect on link items */ + .link-item.fresnel-hover { + background: rgba(79, 195, 247, 0.15); + border-left: 2px solid #4fc3f7; + } + .link-item.flash-highlight { + animation: flashLink 1s ease-out; + } + @keyframes flashLink { + 0%, 100% { background: transparent; } + 50% { background: rgba(79, 195, 247, 0.3); } + } @@ -2556,6 +2588,15 @@ + @@ -2591,6 +2632,8 @@ + + diff --git a/dashboard/js/app.js b/dashboard/js/app.js index 2a8843c..81b7373 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -61,7 +61,13 @@ // Event dedup: set of recently processed event IDs to avoid double-processing // from immediate broadcast + delta buffering recentEventIDs: new Set(), - recentEventIDsPruneAt: 0 + recentEventIDsPruneAt: 0, + // Fresnel debug overlay state + fresnelDebugVisible: false, + fresnelEllipsoids: new Map(), // linkID -> { wireframe, fill, data } + fresnelRaycaster: new THREE.Raycaster(), + fresnelMouse: new THREE.Vector2(), + fresnelHoveredEllipsoid: null }; // ============================================ @@ -1730,4 +1736,282 @@ if (btn) btn.classList.add('active'); } }; + + // ============================================ + // Fresnel Zone Debug Overlay + // ============================================ + + /** + * Toggle Fresnel zone debug overlay for all active links. + * @param {boolean} visible - Whether to show Fresnel zones + */ + window.toggleFresnelDebugOverlay = function(visible) { + state.fresnelDebugVisible = visible; + + if (visible) { + rebuildFresnelDebugEllipsoids(); + } else { + clearFresnelDebugEllipsoids(); + } + }; + + /** + * Rebuild Fresnel zone ellipsoids for all active links. + * Called when the overlay is toggled on or when links change. + */ + function rebuildFresnelDebugEllipsoids() { + if (!state.fresnelDebugVisible) return; + if (!window.Fresnel) { + console.warn('[Fresnel Debug] Fresnel module not loaded'); + return; + } + + // Clear existing ellipsoids + clearFresnelDebugEllipsoids(); + + // Get node positions from Viz3D + var nodeMeshes = Viz3D.getNodeMesh ? Viz3D.getNodeMesh() : new Map(); + + // Create ellipsoids for each active link + state.links.forEach(function(link, linkID) { + var parts = linkID.split(':'); + if (parts.length < 2) return; + + var txMAC = link.nodeMAC || parts[0]; + var rxMAC = link.peerMAC || parts[1]; + + var txMesh = nodeMeshes.get(txMAC); + var rxMesh = nodeMeshes.get(rxMAC); + + if (!txMesh || !rxMesh) return; + + var tx = txMesh.position; + var rx = rxMesh.position; + + // Get channel from link health data (default to 6 for 2.4 GHz) + var healthData = state.worstLinkID === linkID ? { score: state.worstLinkScore } : null; + var channel = 6; // Default 2.4 GHz channel + + // Determine color based on link health + var healthScore = healthData ? healthData.score : 0.5; + var color = getFresnelHealthColor(healthScore); + + // Create Fresnel ellipsoid + var ellipsoid = window.Fresnel.addFresnelEllipsoid(tx, rx, channel, color); + if (ellipsoid) { + // Store link info in userData for interactions + ellipsoid.wireframe.userData.linkID = linkID; + ellipsoid.wireframe.userData.txMAC = txMAC; + ellipsoid.wireframe.userData.rxMAC = rxMAC; + ellipsoid.fill.userData.linkID = linkID; + ellipsoid.fill.userData.txMAC = txMAC; + ellipsoid.fill.userData.rxMAC = rxMAC; + + state.fresnelEllipsoids.set(linkID, ellipsoid); + } + }); + + console.log('[Fresnel Debug] Created ' + state.fresnelEllipsoids.size + ' Fresnel ellipsoids'); + } + + /** + * Clear all Fresnel debug ellipsoids from the scene. + */ + function clearFresnelDebugEllipsoids() { + state.fresnelEllipsoids.forEach(function(ellipsoid) { + if (window.Fresnel) { + window.Fresnel.removeFresnelEllipsoid(ellipsoid); + } + }); + state.fresnelEllipsoids.clear(); + hideFresnelTooltip(); + } + + /** + * Get color for Fresnel zone based on link health score. + * @param {number} score - Health score (0-1) + * @returns {number} Color hex value + */ + function getFresnelHealthColor(score) { + if (score >= 0.7) return 0x66bb6a; // green + if (score >= 0.4) return 0xeab308; // yellow + return 0xef4444; // red + } + + /** + * Handle mouse move events for Fresnel ellipsoid hover detection. + */ + function onFresnelMouseMove(event) { + if (!state.fresnelDebugVisible) return; + + // Calculate mouse position in normalized device coordinates + var rect = renderer.domElement.getBoundingClientRect(); + state.fresnelMouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + state.fresnelMouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + // Raycast against all Fresnel ellipsoids + state.fresnelRaycaster.setFromCamera(state.fresnelMouse, camera); + + var intersects = []; + state.fresnelEllipsoids.forEach(function(ellipsoid) { + var result = state.fresnelRaycaster.intersectObject(ellipsoid.fill, true); + if (result.length > 0) { + intersects.push(result[0]); + } + }); + + if (intersects.length > 0) { + var intersect = intersects[0]; + var linkID = intersect.object.userData.linkID; + + if (state.fresnelHoveredEllipsoid !== linkID) { + // New hover + state.fresnelHoveredEllipsoid = linkID; + highlightFresnelLink(linkID, true); + showFresnelTooltip(event, linkID, intersect.point); + } else { + // Update tooltip position + updateFresnelTooltipPosition(event); + } + } else { + if (state.fresnelHoveredEllipsoid !== null) { + // Hover ended + highlightFresnelLink(state.fresnelHoveredEllipsoid, false); + state.fresnelHoveredEllipsoid = null; + hideFresnelTooltip(); + } + } + } + + /** + * Handle click events on Fresnel ellipsoids. + */ + function onFresnelClick(event) { + if (!state.fresnelDebugVisible || state.fresnelHoveredEllipsoid === null) return; + + var linkID = state.fresnelHoveredEllipsoid; + + // Select the corresponding link in the link panel + selectLink(linkID); + + // Flash the link entry to highlight it + var linkItem = document.querySelector('.link-item[data-link-id="' + linkID + '"]'); + if (linkItem) { + linkItem.classList.add('flash-highlight'); + setTimeout(function() { + linkItem.classList.remove('flash-highlight'); + }, 1000); + } + } + + /** + * Highlight or unhighlight a link when its Fresnel ellipsoid is hovered. + * @param {string} linkID - Link ID + * @param {boolean} highlight - Whether to highlight + */ + function highlightFresnelLink(linkID, highlight) { + var linkItem = document.querySelector('.link-item[data-link-id="' + linkID + '"]'); + if (linkItem) { + if (highlight) { + linkItem.classList.add('fresnel-hover'); + } else { + linkItem.classList.remove('fresnel-hover'); + } + } + + // Also highlight the link line in 3D if Viz3D supports it + if (window.Viz3D && window.Viz3D.highlightLink) { + window.Viz3D.highlightLink(linkID, highlight); + } + } + + /** + * Show tooltip with Fresnel ellipsoid details. + * @param {MouseEvent} event - Mouse event + * @param {string} linkID - Link ID + * @param {THREE.Vector3} point - 3D point of intersection + */ + function showFresnelTooltip(event, linkID, point) { + var tooltip = document.getElementById('fresnel-tooltip'); + if (!tooltip) { + tooltip = document.createElement('div'); + tooltip.id = 'fresnel-tooltip'; + tooltip.className = 'fresnel-tooltip'; + document.body.appendChild(tooltip); + } + + var link = state.links.get(linkID); + var ellipsoid = state.fresnelEllipsoids.get(linkID); + if (!link || !ellipsoid) return; + + var data = ellipsoid.data; + var healthScore = state.worstLinkID === linkID ? state.worstLinkScore : 0.5; + + var txLabel = state.nodes.get(data.txMAC) ? state.nodes.get(data.txMAC).mac : data.txMAC; + var rxLabel = state.nodes.get(data.rxMAC) ? state.nodes.get(data.rxMAC).mac : data.rxMAC; + + tooltip.innerHTML = + 'Link: ' + abbreviateLinkID(linkID) + '
' + + 'TX: ' + txLabel + '
' + + 'RX: ' + rxLabel + '
' + + 'Fresnel radius at midpoint: ' + data.b.toFixed(3) + ' m
' + + 'Link distance: ' + data.d.toFixed(2) + ' m
' + + 'Wavelength: ' + data.lambda.toFixed(3) + ' m (ch ' + data.channel + ')
' + + 'Link health: ' + Math.round(healthScore * 100) + '%'; + + tooltip.style.display = 'block'; + updateFresnelTooltipPosition(event); + } + + /** + * Update tooltip position based on mouse event. + * @param {MouseEvent} event - Mouse event + */ + function updateFresnelTooltipPosition(event) { + var tooltip = document.getElementById('fresnel-tooltip'); + if (!tooltip) return; + + tooltip.style.left = (event.clientX + 15) + 'px'; + tooltip.style.top = (event.clientY + 15) + 'px'; + } + + /** + * Hide the Fresnel tooltip. + */ + function hideFresnelTooltip() { + var tooltip = document.getElementById('fresnel-tooltip'); + if (tooltip) { + tooltip.style.display = 'none'; + } + } + + // Add Fresnel interaction event listeners after scene initialization + var originalInitScene = initScene; + initScene = function() { + originalInitScene(); + + // Initialize Fresnel module with scene + if (window.Fresnel && window.Fresnel.init) { + window.Fresnel.init(scene); + } + + // Add event listeners for Fresnel interaction + renderer.domElement.addEventListener('mousemove', onFresnelMouseMove); + renderer.domElement.addEventListener('click', onFresnelClick); + + // Show debug section if expert mode (always visible for now) + var debugSection = document.getElementById('debug-section'); + if (debugSection) { + debugSection.style.display = 'block'; + } + }; + + // Update Fresnel ellipsoids when links change + var originalUpdateLinkList = updateLinkList; + updateLinkList = function() { + originalUpdateLinkList(); + if (state.fresnelDebugVisible) { + rebuildFresnelDebugEllipsoids(); + } + }; })(); diff --git a/dashboard/js/fresnel.js b/dashboard/js/fresnel.js new file mode 100644 index 0000000..ddb83ce --- /dev/null +++ b/dashboard/js/fresnel.js @@ -0,0 +1,269 @@ +/** + * Spaxel Dashboard - Fresnel Zone Helper Module + * + * Shared Fresnel zone ellipsoid geometry computation for: + * - Debug overlay (all active links) + * - Explainability overlay (contributing links for a specific blob) + * + * Provides FresnelEllipsoid() function that returns Three.js meshes + * ready for scene insertion. + */ + +(function() { + 'use strict'; + + // ============================================ + // Configuration + // ============================================ + const CONFIG = { + // WiFi wavelengths (c/f in meters) + wavelength_5ghz: 0.06, // 5 GHz + wavelength_2_4ghz: 0.125, // 2.4 GHz + // Geometry + sphereSegments: 32, // Sphere geometry segments (reduced on mobile) + sphereHeightSegments: 16, + wireframeOpacity: 0.6, // Line opacity for wireframe + fillOpacity: 0.08, // Fill opacity + mobileViewportWidth: 768, // Width threshold for mobile + mobileSegments: 16, // Reduced segments for mobile + mobileHeightSegments: 8 + }; + + // ============================================ + // Private State + // ============================================ + let _scene = null; + + /** + * Initialize the Fresnel module with the Three.js scene. + * @param {THREE.Scene} scene - The Three.js scene + */ + function init(scene) { + _scene = scene; + } + + /** + * Get WiFi wavelength based on channel number. + * @param {number} channel - WiFi channel (1-14 for 2.4 GHz, 36-165 for 5 GHz) + * @returns {number} Wavelength in meters + */ + function getWavelengthForChannel(channel) { + if (!channel || channel < 1) return CONFIG.wavelength_2_4ghz; + + // 2.4 GHz channels: 1-14 + if (channel <= 14) { + return CONFIG.wavelength_2_4ghz; + } + + // 5 GHz channels: 36 and above + return CONFIG.wavelength_5ghz; + } + + /** + * Calculate Fresnel zone ellipsoid parameters for a link. + * Based on the first Fresnel zone geometry. + * + * For a link with TX at position P1 and RX at position P2: + * - Link distance d = |P1 - P2| + * - WiFi wavelength lambda: 5 GHz -> lambda = 0.06m, 2.4 GHz -> lambda = 0.125m + * - Semi-major axis: a = (d + lambda/2) / 2 + * - Semi-minor axis: b = sqrt(a^2 - (d/2)^2) + * - Ellipsoid centre: midpoint(P1, P2) + * - Ellipsoid orientation: major axis along the P1->P2 unit vector + * + * @param {THREE.Vector3} tx - Transmitter position + * @param {THREE.Vector3} rx - Receiver position + * @param {number} channel - WiFi channel number (for wavelength) + * @returns {Object} Ellipsoid parameters: { center, semiAxes, rotation, lambda, d, a, b } + */ + function calculateFresnelEllipsoid(tx, rx, channel) { + // Get wavelength based on channel + const lambda = getWavelengthForChannel(channel); + + // Direct distance between TX and RX + const d = tx.distanceTo(rx); + + // First Fresnel zone ellipsoid parameters + // Semi-major axis: a = (d + lambda/2) / 2 + const a = (d + lambda / 2) / 2; + + // Semi-minor axis: b = sqrt(a^2 - (d/2)^2) + // Using the property that for a prolate spheroid with foci at tx and rx: + // b^2 = a^2 - (d/2)^2 + const b = Math.sqrt(Math.max(0, a * a - (d / 2) * (d / 2))); + + // Center of ellipsoid (midpoint between TX and RX) + const center = new THREE.Vector3().addVectors(tx, rx).multiplyScalar(0.5); + + // Rotation: align with TX-RX axis + // We want the Z-axis of the ellipsoid to point along the link direction + const direction = new THREE.Vector3().subVectors(rx, tx).normalize(); + const up = new THREE.Vector3(0, 0, 1); // Z-axis is up in our ellipsoid geometry + const quaternion = new THREE.Quaternion().setFromUnitVectors(up, direction); + + return { + center: center, + semiAxes: new THREE.Vector3(b, b, a), // X, Y (minor), Z (major along link) + rotation: quaternion, + lambda: lambda, + d: d, + a: a, + b: b, + channel: channel + }; + } + + /** + * Create a Three.js Mesh for a Fresnel zone ellipsoid. + * Creates both wireframe and fill meshes for proper visualization. + * + * @param {THREE.Vector3} tx - Transmitter position + * @param {THREE.Vector3} rx - Receiver position + * @param {number} channel - WiFi channel number + * @param {number} color - Color hex value (e.g., 0x4FC3F7 for blue) + * @param {Object} options - Optional settings { wireframeOpacity, fillOpacity } + * @returns {Object} Object containing { wireframe, fill, data } meshes + */ + function FresnelEllipsoid(tx, rx, channel, color, options) { + if (!_scene) { + console.warn('[Fresnel] Scene not initialized. Call Fresnel.init(scene) first.'); + return null; + } + + options = options || {}; + const wireframeOpacity = options.wireframeOpacity !== undefined ? options.wireframeOpacity : CONFIG.wireframeOpacity; + const fillOpacity = options.fillOpacity !== undefined ? options.fillOpacity : CONFIG.fillOpacity; + + // Calculate ellipsoid geometry + const ellipsoid = calculateFresnelEllipsoid(tx, rx, channel); + + // Determine segment count based on viewport (mobile optimization) + const isMobile = window.innerWidth < CONFIG.mobileViewportWidth; + const segments = isMobile ? CONFIG.mobileSegments : CONFIG.sphereSegments; + const heightSegments = isMobile ? CONFIG.mobileHeightSegments : CONFIG.sphereHeightSegments; + + // Create sphere geometry (unit sphere that will be scaled) + const geometry = new THREE.SphereGeometry(1, segments, heightSegments); + + // Apply non-uniform scaling to create ellipsoid + // We scale after creation to get the correct ellipsoid shape + geometry.scale(ellipsoid.semiAxes.x, ellipsoid.semiAxes.y, ellipsoid.semiAxes.z); + + // Create wireframe using EdgesGeometry for crisp edges + const edgesGeometry = new THREE.EdgesGeometry(geometry); + const wireframeMaterial = new THREE.LineBasicMaterial({ + color: color, + transparent: true, + opacity: wireframeOpacity, + depthTest: true, + depthWrite: false + }); + const wireframe = new THREE.LineSegments(edgesGeometry, wireframeMaterial); + + // Create fill mesh + const fillMaterial = new THREE.MeshBasicMaterial({ + color: color, + transparent: true, + opacity: fillOpacity, + depthWrite: false, + side: THREE.DoubleSide + }); + const fill = new THREE.Mesh(geometry, fillMaterial); + + // Position at ellipsoid center + wireframe.position.copy(ellipsoid.center); + fill.position.copy(ellipsoid.center); + + // Apply rotation to align with link axis + wireframe.quaternion.copy(ellipsoid.rotation); + fill.quaternion.copy(ellipsoid.rotation); + + // Store metadata for raycasting and interactions + const data = { + tx: tx.clone(), + rx: rx.clone(), + channel: channel, + lambda: ellipsoid.lambda, + d: ellipsoid.d, + a: ellipsoid.a, + b: ellipsoid.b, + semiAxes: ellipsoid.semiAxes.clone(), + center: ellipsoid.center.clone(), + rotation: ellipsoid.rotation.clone() + }; + + wireframe.userData = { fresnelEllipsoid: data }; + fill.userData = { fresnelEllipsoid: data }; + + return { + wireframe: wireframe, + fill: fill, + data: data + }; + } + + /** + * Add a Fresnel ellipsoid to the scene. + * Convenience function that creates and adds the mesh. + * + * @param {THREE.Vector3} tx - Transmitter position + * @param {THREE.Vector3} rx - Receiver position + * @param {number} channel - WiFi channel number + * @param {number} color - Color hex value + * @param {Object} options - Optional settings + * @returns {Object} Object containing { wireframe, fill, data } + */ + function addFresnelEllipsoid(tx, rx, channel, color, options) { + const ellipsoid = FresnelEllipsoid(tx, rx, channel, color, options); + if (!ellipsoid) return null; + + if (_scene) { + _scene.add(ellipsoid.wireframe); + _scene.add(ellipsoid.fill); + } + + return ellipsoid; + } + + /** + * Remove a Fresnel ellipsoid from the scene. + * + * @param {Object} ellipsoid - Object returned from addFresnelEllipsoid or FresnelEllipsoid + */ + function removeFresnelEllipsoid(ellipsoid) { + if (!ellipsoid) return; + + if (ellipsoid.wireframe && _scene) { + _scene.remove(ellipsoid.wireframe); + ellipsoid.wireframe.geometry.dispose(); + if (ellipsoid.wireframe.material) { + ellipsoid.wireframe.material.dispose(); + } + } + + if (ellipsoid.fill && _scene) { + _scene.remove(ellipsoid.fill); + if (ellipsoid.fill.geometry) { + ellipsoid.fill.geometry.dispose(); + } + if (ellipsoid.fill.material) { + ellipsoid.fill.material.dispose(); + } + } + } + + // ============================================ + // Public API + // ============================================ + window.Fresnel = { + init: init, + calculateFresnelEllipsoid: calculateFresnelEllipsoid, + FresnelEllipsoid: FresnelEllipsoid, + addFresnelEllipsoid: addFresnelEllipsoid, + removeFresnelEllipsoid: removeFresnelEllipsoid, + // Configuration access + CONFIG: CONFIG + }; + + console.log('[Fresnel] Module loaded'); +})(); diff --git a/dashboard/js/fresnel.test.js b/dashboard/js/fresnel.test.js new file mode 100644 index 0000000..1998702 --- /dev/null +++ b/dashboard/js/fresnel.test.js @@ -0,0 +1,316 @@ +/** + * Spaxel Dashboard - Fresnel Zone Tests + * + * Tests for Fresnel ellipsoid geometry computation. + * These tests verify the correctness of the Fresnel zone calculations + * used for both the debug overlay and explainability overlay. + */ + +describe('Fresnel Module', function() { + // Mock THREE.js before all tests + beforeAll(function() { + if (typeof THREE === 'undefined') { + global.THREE = { + Vector3: function(x, y, z) { + this.x = x || 0; + this.y = y || 0; + this.z = z || 0; + }, + Quaternion: function() { + this._x = 0; + this._y = 0; + this._z = 0; + this._w = 1; + } + }; + + // Add prototype methods to Vector3 + THREE.Vector3.prototype.distanceTo = function(v) { + return Math.sqrt( + Math.pow(this.x - v.x, 2) + + Math.pow(this.y - v.y, 2) + + Math.pow(this.z - v.z, 2) + ); + }; + + THREE.Vector3.prototype.addVectors = function(v1, v2) { + this.x = v1.x + v2.x; + this.y = v1.y + v2.y; + this.z = v1.z + v2.z; + return this; + }; + + THREE.Vector3.prototype.subVectors = function(v1, v2) { + this.x = v1.x - v2.x; + this.y = v1.y - v2.y; + this.z = v1.z - v2.z; + return this; + }; + + THREE.Vector3.prototype.multiplyScalar = function(s) { + this.x *= s; + this.y *= s; + this.z *= s; + return this; + }; + + THREE.Vector3.prototype.clone = function() { + return new THREE.Vector3(this.x, this.y, this.z); + }; + + THREE.Quaternion.prototype.setFromUnitVectors = function(from, to) { + // Simplified quaternion for testing + this._w = 1; + return this; + }; + + THREE.Quaternion.prototype.clone = function() { + return new THREE.Quaternion(); + }; + } + + // Load the Fresnel module + if (typeof window !== 'undefined') { + // In browser environment, the module should already be loaded + // For testing purposes, we'll use the functions directly + } + }); + + describe('calculateFresnelEllipsoid', function() { + var calculateFresnelEllipsoid; + + beforeEach(function() { + if (window.Fresnel && window.Fresnel.calculateFresnelEllipsoid) { + calculateFresnelEllipsoid = window.Fresnel.calculateFresnelEllipsoid; + } + }); + + it('should calculate correct semi-major and semi-minor axes for a 4m link', function() { + if (!calculateFresnelEllipsoid) { + test.skip('Fresnel module not loaded'); + return; + } + + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(4, 0, 0); + var channel = 6; // 2.4 GHz + + var result = calculateFresnelEllipsoid(tx, rx, channel); + + // Expected values: + // lambda = 0.125m (2.4 GHz) + // d = 4m + // a = (d + lambda/2) / 2 = (4 + 0.0625) / 2 = 2.03125 + // b = sqrt(a^2 - (d/2)^2) = sqrt(2.03125^2 - 2^2) = sqrt(4.126 - 4) = sqrt(0.126) ≈ 0.355 + + expect(result.d).toBeCloseTo(4, 0.01); + expect(result.a).toBeCloseTo(2.031, 0.001); + expect(result.b).toBeCloseTo(0.355, 0.001); + }); + + it('should calculate correct semi-minor axis for very short link (edge case)', function() { + if (!calculateFresnelEllipsoid) { + test.skip('Fresnel module not loaded'); + return; + } + + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(0.1, 0, 0); + var channel = 6; + + var result = calculateFresnelEllipsoid(tx, rx, channel); + + // For a very short link, b should be small but positive + expect(result.b).toBeGreaterThan(0); + expect(result.b).toBeLessThan(0.1); + }); + + it('should calculate correct axes for diagonal link (distance = 5m)', function() { + if (!calculateFresnelEllipsoid) { + test.skip('Fresnel module not loaded'); + return; + } + + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(3, 4, 0); // 3-4-5 triangle, distance = 5 + var channel = 6; + + var result = calculateFresnelEllipsoid(tx, rx, channel); + + // Expected: + // d = 5m + // lambda = 0.125m + // a = (5 + 0.0625) / 2 = 2.53125 + // b = sqrt(2.53125^2 - 2.5^2) = sqrt(6.407 - 6.25) = sqrt(0.157) ≈ 0.396 + + expect(result.d).toBeCloseTo(5, 0.01); + expect(result.a).toBeCloseTo(2.531, 0.001); + expect(result.b).toBeCloseTo(0.396, 0.001); + }); + + it('should use correct wavelength for 5 GHz channel', function() { + if (!calculateFresnelEllipsoid) { + test.skip('Fresnel module not loaded'); + return; + } + + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(4, 0, 0); + var channel = 36; // 5 GHz channel + + var result = calculateFresnelEllipsoid(tx, rx, channel); + + // For 5 GHz: lambda = 0.06m + // a = (4 + 0.03) / 2 = 2.015 + // b = sqrt(2.015^2 - 2^2) = sqrt(4.060 - 4) = sqrt(0.060) ≈ 0.245 + + expect(result.lambda).toBeCloseTo(0.06, 0.001); + expect(result.a).toBeCloseTo(2.015, 0.001); + expect(result.b).toBeCloseTo(0.245, 0.001); + }); + + it('should use correct wavelength for 2.4 GHz channel', function() { + if (!calculateFresnelEllipsoid) { + test.skip('Fresnel module not loaded'); + return; + } + + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(4, 0, 0); + var channel = 6; // 2.4 GHz channel + + var result = calculateFresnelEllipsoid(tx, rx, channel); + + // For 2.4 GHz: lambda = 0.125m + expect(result.lambda).toBeCloseTo(0.125, 0.001); + }); + + it('should calculate ellipsoid center at midpoint', function() { + if (!calculateFresnelEllipsoid) { + test.skip('Fresnel module not loaded'); + return; + } + + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(4, 0, 0); + var channel = 6; + + var result = calculateFresnelEllipsoid(tx, rx, channel); + + expect(result.center.x).toBeCloseTo(2, 0.001); + expect(result.center.y).toBeCloseTo(0, 0.001); + expect(result.center.z).toBeCloseTo(0, 0.001); + }); + + it('should calculate ellipsoid center for diagonal link', function() { + if (!calculateFresnelEllipsoid) { + test.skip('Fresnel module not loaded'); + return; + } + + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(6, 8, 0); + var channel = 6; + + var result = calculateFresnelEllipsoid(tx, rx, channel); + + // Midpoint of (0,0,0) and (6,8,0) is (3,4,0) + expect(result.center.x).toBeCloseTo(3, 0.001); + expect(result.center.y).toBeCloseTo(4, 0.001); + expect(result.center.z).toBeCloseTo(0, 0.001); + }); + + it('should handle zero channel gracefully (default to 2.4 GHz)', function() { + if (!calculateFresnelEllipsoid) { + test.skip('Fresnel module not loaded'); + return; + } + + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(4, 0, 0); + var channel = 0; // Invalid channel + + var result = calculateFresnelEllipsoid(tx, rx, channel); + + // Should default to 2.4 GHz wavelength + expect(result.lambda).toBeCloseTo(0.125, 0.001); + }); + + it('should handle very large link distance (100m)', function() { + if (!calculateFresnelEllipsoid) { + test.skip('Fresnel module not loaded'); + return; + } + + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(100, 0, 0); + var channel = 6; + + var result = calculateFresnelEllipsoid(tx, rx, channel); + + // For 100m link: + // a = (100 + 0.0625) / 2 = 50.03125 + // b = sqrt(50.03125^2 - 50^2) = sqrt(2503.125 - 2500) = sqrt(3.125) ≈ 1.768 + + expect(result.d).toBeCloseTo(100, 0.01); + expect(result.a).toBeCloseTo(50.031, 0.001); + expect(result.b).toBeCloseTo(1.768, 0.001); + }); + }); + + describe('getWavelengthForChannel', function() { + var getWavelengthForChannel; + + beforeEach(function() { + if (window.Fresnel && window.Fresnel.calculateFresnelEllipsoid) { + // Test the wavelength indirectly through calculateFresnelEllipsoid + getWavelengthForChannel = function(channel) { + var tx = new THREE.Vector3(0, 0, 0); + var rx = new THREE.Vector3(1, 0, 0); + var result = window.Fresnel.calculateFresnelEllipsoid(tx, rx, channel); + return result.lambda; + }; + } + }); + + it('should return 2.4 GHz wavelength for channel 1', function() { + if (!getWavelengthForChannel) { + test.skip('Fresnel module not loaded'); + return; + } + expect(getWavelengthForChannel(1)).toBeCloseTo(0.125, 0.001); + }); + + it('should return 2.4 GHz wavelength for channel 6', function() { + if (!getWavelengthForChannel) { + test.skip('Fresnel module not loaded'); + return; + } + expect(getWavelengthForChannel(6)).toBeCloseTo(0.125, 0.001); + }); + + it('should return 2.4 GHz wavelength for channel 14', function() { + if (!getWavelengthForChannel) { + test.skip('Fresnel module not loaded'); + return; + } + expect(getWavelengthForChannel(14)).toBeCloseTo(0.125, 0.001); + }); + + it('should return 5 GHz wavelength for channel 36', function() { + if (!getWavelengthForChannel) { + test.skip('Fresnel module not loaded'); + return; + } + expect(getWavelengthForChannel(36)).toBeCloseTo(0.06, 0.001); + }); + + it('should return 5 GHz wavelength for channel 149', function() { + if (!getWavelengthForChannel) { + test.skip('Fresnel module not loaded'); + return; + } + expect(getWavelengthForChannel(149)).toBeCloseTo(0.06, 0.001); + }); + }); +});