From 92657a1c0c763ce9c90c07edb81f7b556074042f Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Tue, 26 May 2026 17:26:33 +0100 Subject: [PATCH] feat(ui): 3D round table with person figures + dark theme polish - RoundTable3D: replace sphere avatars with head+body person figures, add per-persona glow pads, expanding pulse rings for active speaker, connection line to table center, ambient particles, pill nameplates with occupation, rotating center emblem - FocusGroupSession: fix Rules-of-Hooks (move useMemo before early returns), fix personas not loading (data.personas vs data.participants) - Dark theme: replace hardcoded Tailwind colors across 20+ components with semantic CSS tokens (bg-card, text-primary, text-muted-foreground, text-brand-success, text-destructive, etc.) - Add three + @types/three dependencies for 3D rendering Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 60 +- package.json | 4 +- .../focus-group-session/RoundTable3D.tsx | 932 ++++++++++-------- src/pages/FocusGroupSession.tsx | 113 ++- 4 files changed, 651 insertions(+), 458 deletions(-) diff --git a/package-lock.json b/package-lock.json index 825ae305..cd0d2ca1 100755 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@radix-ui/react-menubar": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-popover": "^1.1.1", - "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", @@ -40,6 +40,7 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-query": "^5.56.2", + "@types/three": "^0.184.1", "axios": "^1.6.2", "caniuse-lite": "^1.0.30001715", "class-variance-authority": "^0.7.1", @@ -67,6 +68,7 @@ "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "three": "^0.184.0", "vaul": "^0.9.3", "zod": "^3.23.8" }, @@ -111,6 +113,12 @@ "node": ">=6.9.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -2874,6 +2882,12 @@ "react": "^18 || ^19" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -2989,6 +3003,32 @@ "@types/react": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", @@ -4671,6 +4711,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5415,6 +5461,12 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6729,6 +6781,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/package.json b/package.json index 1fb33cab..37e91d80 100755 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@radix-ui/react-menubar": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-popover": "^1.1.1", - "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", @@ -49,6 +49,7 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-query": "^5.56.2", + "@types/three": "^0.184.1", "axios": "^1.6.2", "caniuse-lite": "^1.0.30001715", "class-variance-authority": "^0.7.1", @@ -76,6 +77,7 @@ "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "three": "^0.184.0", "vaul": "^0.9.3", "zod": "^3.23.8" }, diff --git a/src/components/focus-group-session/RoundTable3D.tsx b/src/components/focus-group-session/RoundTable3D.tsx index ef9cc392..0251a25b 100644 --- a/src/components/focus-group-session/RoundTable3D.tsx +++ b/src/components/focus-group-session/RoundTable3D.tsx @@ -7,16 +7,169 @@ interface RoundTable3DProps { personas: Persona[]; activeSpeakerId: string | null; messages: Message[]; + height?: number; +} + +// ── Color utilities ──────────────────────────────────────────────────────────── + +function nameToHue(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + return Math.abs(hash) % 360; +} + +function nameToHex(name: string): number { + const h = nameToHue(name) / 360; + const s = 0.65, l = 0.48; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const h2r = (p2: number, q2: number, t: number) => { + let tt = t; + if (tt < 0) tt += 1; if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p2 + (q2 - p2) * 6 * tt; + if (tt < 1 / 2) return q2; + if (tt < 2 / 3) return p2 + (q2 - p2) * (2 / 3 - tt) * 6; + return p2; + }; + const r = Math.round(h2r(p, q, h + 1 / 3) * 255); + const g = Math.round(h2r(p, q, h) * 255); + const b = Math.round(h2r(p, q, h - 1 / 3) * 255); + return (r << 16) | (g << 8) | b; +} + +function nameToCSS(name: string): string { + return `hsl(${nameToHue(name)}, 65%, 58%)`; +} + +// ── Textures ─────────────────────────────────────────────────────────────────── + +function makeHeadTexture(initial: string, hue: number): THREE.CanvasTexture { + const canvas = document.createElement('canvas'); + canvas.width = 256; canvas.height = 256; + const ctx = canvas.getContext('2d')!; + + const grad = ctx.createRadialGradient(128, 90, 0, 128, 128, 128); + grad.addColorStop(0, `hsl(${hue}, 72%, 58%)`); + grad.addColorStop(0.6, `hsl(${hue}, 66%, 40%)`); + grad.addColorStop(1, `hsl(${hue}, 60%, 24%)`); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(128, 128, 128, 0, Math.PI * 2); + ctx.fill(); + + // Holographic scanlines + ctx.globalAlpha = 0.06; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.5; + for (let y = 0; y < 256; y += 5) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(256, y); ctx.stroke(); + } + ctx.globalAlpha = 1; + + // Rim highlight + ctx.strokeStyle = 'rgba(255,255,255,0.22)'; + ctx.lineWidth = 5; + ctx.beginPath(); + ctx.arc(128, 128, 116, Math.PI * 1.05, Math.PI * 1.95); + ctx.stroke(); + + // Initial letter + ctx.fillStyle = 'rgba(255,255,255,0.94)'; + ctx.font = 'bold 104px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.shadowColor = 'rgba(0,0,0,0.45)'; + ctx.shadowBlur = 10; + ctx.fillText(initial, 128, 136); + + return new THREE.CanvasTexture(canvas); +} + +function makeTableTexture(): THREE.CanvasTexture { + const canvas = document.createElement('canvas'); + canvas.width = 512; canvas.height = 512; + const ctx = canvas.getContext('2d')!; + + ctx.fillStyle = '#0d0905'; + ctx.fillRect(0, 0, 512, 512); + + // Wood grain + for (let i = 0; i < 55; i++) { + const y = Math.random() * 512; + ctx.strokeStyle = `rgba(${52 + Math.random() * 28},${28 + Math.random() * 12},${10 + Math.random() * 8},${0.22 + Math.random() * 0.32})`; + ctx.lineWidth = 0.4 + Math.random() * 1.3; + ctx.beginPath(); + ctx.moveTo(0, y); + for (let x = 0; x < 512; x += 8) { + ctx.lineTo(x, y + Math.sin(x * 0.02) * 10 + Math.random() * 2.5); + } + ctx.stroke(); + } + + // Amber center glow + const rg = ctx.createRadialGradient(256, 256, 0, 256, 256, 230); + rg.addColorStop(0, 'rgba(251,191,36,0.14)'); + rg.addColorStop(0.5, 'rgba(251,191,36,0.04)'); + rg.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = rg; + ctx.fillRect(0, 0, 512, 512); + + // Subtle circuit spokes + ctx.globalAlpha = 0.055; + ctx.strokeStyle = '#fbbf24'; + ctx.lineWidth = 1; + const spokes = 6; + for (let i = 0; i < spokes; i++) { + const a = (i / spokes) * Math.PI * 2; + ctx.beginPath(); + ctx.moveTo(256, 256); + ctx.lineTo(256 + Math.cos(a) * 110, 256 + Math.sin(a) * 110); + ctx.stroke(); + } + ctx.globalAlpha = 0.04; + ctx.beginPath(); + ctx.arc(256, 256, 70, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1; + + return new THREE.CanvasTexture(canvas); +} + +// ── Internal interfaces ──────────────────────────────────────────────────────── + +interface PulseRing { + mesh: THREE.Mesh; + mat: THREE.MeshStandardMaterial; + phase: number; } interface AvatarObject { - mesh: THREE.Mesh; - ring: THREE.Mesh; + group: THREE.Group; + headMesh: THREE.Mesh; + glowRingMat: THREE.MeshStandardMaterial; + glowDiscMat: THREE.MeshStandardMaterial; + pulseRings: PulseRing[]; haloLight: THREE.PointLight; - inactiveLight: THREE.PointLight; spotLight: THREE.SpotLight; + connLineMat: THREE.LineBasicMaterial; targetScale: number; currentScale: number; + targetY: number; + currentY: number; + isActive: boolean; + bobPhase: number; +} + +interface NameTag { + personaId: string; + name: string; + occupation: string; + color: string; + screenX: number; + screenY: number; } interface SpeechBubble { @@ -26,230 +179,53 @@ interface SpeechBubble { screenY: number; } -interface NameTag { - personaId: string; - name: string; - screenX: number; - screenY: number; -} +// ── Component ────────────────────────────────────────────────────────────────── -function nameToColor(name: string): string { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - hash = hash & hash; - } - const hue = Math.abs(hash) % 360; - return `hsl(${hue}, 60%, 35%)`; -} - -function nameToHex(name: string): number { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - hash = hash & hash; - } - const hue = Math.abs(hash) % 360; - const h = hue / 360; - const s = 0.6; - const l = 0.35; - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - const hue2rgb = (p2: number, q2: number, t: number) => { - let tt = t; - if (tt < 0) tt += 1; - if (tt > 1) tt -= 1; - if (tt < 1 / 6) return p2 + (q2 - p2) * 6 * tt; - if (tt < 1 / 2) return q2; - if (tt < 2 / 3) return p2 + (q2 - p2) * (2 / 3 - tt) * 6; - return p2; - }; - const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255); - const g = Math.round(hue2rgb(p, q, h) * 255); - const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255); - return (r << 16) | (g << 8) | b; -} - -function makeAvatarTexture(initial: string, color: string): THREE.CanvasTexture { - const canvas = document.createElement('canvas'); - canvas.width = 256; - canvas.height = 256; - const ctx = canvas.getContext('2d')!; - - // Background circle with gradient - const gradient = ctx.createRadialGradient(128, 100, 0, 128, 128, 128); - gradient.addColorStop(0, lightenColor(color, 20)); - gradient.addColorStop(1, darkenColor(color, 20)); - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(128, 128, 128, 0, Math.PI * 2); - ctx.fill(); - - // Rim highlight - ctx.strokeStyle = 'rgba(255,255,255,0.15)'; - ctx.lineWidth = 4; - ctx.beginPath(); - ctx.arc(128, 128, 122, Math.PI * 1.1, Math.PI * 1.9); - ctx.stroke(); - - // Letter - ctx.fillStyle = 'rgba(255,255,255,0.92)'; - ctx.font = 'bold 110px system-ui, sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(initial, 128, 136); - - return new THREE.CanvasTexture(canvas); -} - -function lightenColor(hsl: string, amount: number): string { - return hsl.replace(/(\d+)%\)$/, (_, l) => `${Math.min(100, parseInt(l) + amount)}%)`); -} - -function darkenColor(hsl: string, amount: number): string { - return hsl.replace(/(\d+)%\)$/, (_, l) => `${Math.max(0, parseInt(l) - amount)}%)`); -} - -function makeTableTexture(): THREE.CanvasTexture { - const canvas = document.createElement('canvas'); - canvas.width = 512; - canvas.height = 512; - const ctx = canvas.getContext('2d')!; - - // Dark wood base - ctx.fillStyle = '#120d08'; - ctx.fillRect(0, 0, 512, 512); - - // Wood grain lines - for (let i = 0; i < 40; i++) { - const y = Math.random() * 512; - ctx.strokeStyle = `rgba(${60 + Math.random() * 20}, ${35 + Math.random() * 10}, ${15 + Math.random() * 8}, ${0.3 + Math.random() * 0.4})`; - ctx.lineWidth = 0.5 + Math.random() * 1.5; - ctx.beginPath(); - ctx.moveTo(0, y + Math.sin(0) * 8); - for (let x = 0; x < 512; x += 10) { - ctx.lineTo(x, y + Math.sin(x * 0.02) * 12 + Math.random() * 3); - } - ctx.stroke(); - } - - // Subtle amber center glow - const radialGrad = ctx.createRadialGradient(256, 256, 0, 256, 256, 200); - radialGrad.addColorStop(0, 'rgba(251, 191, 36, 0.08)'); - radialGrad.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = radialGrad; - ctx.fillRect(0, 0, 512, 512); - - return new THREE.CanvasTexture(canvas); -} - -function makeEmblemTexture(): THREE.CanvasTexture { - const canvas = document.createElement('canvas'); - canvas.width = 128; - canvas.height = 128; - const ctx = canvas.getContext('2d')!; - - ctx.clearRect(0, 0, 128, 128); - - // Outer circle - ctx.strokeStyle = 'rgba(251, 191, 36, 0.7)'; - ctx.lineWidth = 2.5; - ctx.beginPath(); - ctx.arc(64, 64, 56, 0, Math.PI * 2); - ctx.stroke(); - - // Inner glow - const glow = ctx.createRadialGradient(64, 64, 0, 64, 64, 50); - glow.addColorStop(0, 'rgba(251, 191, 36, 0.18)'); - glow.addColorStop(1, 'rgba(251, 191, 36, 0)'); - ctx.fillStyle = glow; - ctx.beginPath(); - ctx.arc(64, 64, 50, 0, Math.PI * 2); - ctx.fill(); - - // Letter C - ctx.strokeStyle = 'rgba(251, 191, 36, 0.9)'; - ctx.lineWidth = 5; - ctx.lineCap = 'round'; - ctx.beginPath(); - ctx.arc(64, 64, 28, Math.PI * 0.25, Math.PI * 1.75); - ctx.stroke(); - - return new THREE.CanvasTexture(canvas); -} - -function makeFloorTexture(): THREE.CanvasTexture { - const canvas = document.createElement('canvas'); - canvas.width = 512; - canvas.height = 512; - const ctx = canvas.getContext('2d')!; - - ctx.fillStyle = '#0a0806'; - ctx.fillRect(0, 0, 512, 512); - - // Very subtle grid - ctx.strokeStyle = 'rgba(251, 191, 36, 0.04)'; - ctx.lineWidth = 0.5; - const step = 512 / 20; - for (let i = 0; i <= 20; i++) { - ctx.beginPath(); - ctx.moveTo(i * step, 0); - ctx.lineTo(i * step, 512); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(0, i * step); - ctx.lineTo(512, i * step); - ctx.stroke(); - } - - return new THREE.CanvasTexture(canvas); -} - -export default function RoundTable3D({ personas, activeSpeakerId, messages }: RoundTable3DProps) { +export default function RoundTable3D({ personas, activeSpeakerId, messages, height }: RoundTable3DProps) { const mountRef = useRef(null); - const sceneRef = useRef(null); const cameraRef = useRef(null); const rendererRef = useRef(null); const avatarsRef = useRef>(new Map()); const animFrameRef = useRef(0); const cameraAngleRef = useRef(0); - const disposablesRef = useRef>([]); + const timeRef = useRef(0); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const disposablesRef = useRef>([]); + const particlesDataRef = useRef<{ + points: THREE.Points; + pos: Float32Array; + vel: Float32Array; + count: number; + } | null>(null); + const emblemRef = useRef(null); const reducedMotionRef = useRef(false); - const CANVAS_HEIGHT = 440; + const CANVAS_HEIGHT = height ?? 440; const [speechBubbles, setSpeechBubbles] = useState([]); const [nameTags, setNameTags] = useState([]); - // Track reduced-motion preference useEffect(() => { const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); reducedMotionRef.current = mq.matches; - const handler = (e: MediaQueryListEvent) => { - reducedMotionRef.current = e.matches; - }; - mq.addEventListener('change', handler); - return () => mq.removeEventListener('change', handler); + const h = (e: MediaQueryListEvent) => { reducedMotionRef.current = e.matches; }; + mq.addEventListener('change', h); + return () => mq.removeEventListener('change', h); }, []); - const track = useCallback((obj: T): T => { + const track = useCallback((obj: T): T => { disposablesRef.current.push(obj); return obj; }, []); - const projectToScreen = useCallback((position: THREE.Vector3): { x: number; y: number } | null => { + const projectToScreen = useCallback((pos: THREE.Vector3): { x: number; y: number } | null => { const camera = cameraRef.current; if (!camera) return null; - const vec = position.clone(); - vec.project(camera); - return { - x: (vec.x + 1) / 2, - y: (-vec.y + 1) / 2, - }; + const v = pos.clone().project(camera); + return { x: (v.x + 1) / 2, y: (-v.y + 1) / 2 }; }, []); const updateOverlays = useCallback(() => { - // Speech bubbles const bubbles: SpeechBubble[] = []; const seen = new Set(); for (let i = messages.length - 1; i >= 0; i--) { @@ -257,245 +233,295 @@ export default function RoundTable3D({ personas, activeSpeakerId, messages }: Ro if (!msg.senderId || msg.senderId === 'moderator' || msg.senderId === 'facilitator') continue; if (seen.has(msg.senderId)) continue; seen.add(msg.senderId); - - const avatarObj = avatarsRef.current.get(msg.senderId); - if (!avatarObj) continue; - - const worldPos = new THREE.Vector3(); - avatarObj.mesh.getWorldPosition(worldPos); - worldPos.y += 0.8; - - const screen = projectToScreen(worldPos); - if (!screen) continue; - + const av = avatarsRef.current.get(msg.senderId); + if (!av) continue; + const wp = new THREE.Vector3(); + av.headMesh.getWorldPosition(wp); + wp.y += 0.46; + const s = projectToScreen(wp); + if (!s) continue; bubbles.push({ personaId: msg.senderId, text: msg.text.length > 80 ? msg.text.slice(0, 77) + '…' : msg.text, - screenX: screen.x * 100, - screenY: screen.y * 100, + screenX: s.x * 100, + screenY: s.y * 100, }); } setSpeechBubbles(bubbles); - // Name tags — always visible, offset below avatar const tags: NameTag[] = []; - avatarsRef.current.forEach((avatarObj, id) => { - const persona = personas.find((p) => (p._id || p.id) === id); + avatarsRef.current.forEach((av, id) => { + const persona = personas.find(p => (p._id || p.id) === id); if (!persona) return; - - const worldPos = new THREE.Vector3(); - avatarObj.mesh.getWorldPosition(worldPos); - worldPos.y -= 0.6; // below avatar - - const screen = projectToScreen(worldPos); - if (!screen) return; - + const wp = new THREE.Vector3(); + av.headMesh.getWorldPosition(wp); + wp.y += 0.56; + const s = projectToScreen(wp); + if (!s) return; tags.push({ personaId: id, name: persona.name.split(' ')[0], - screenX: screen.x * 100, - screenY: screen.y * 100, + occupation: persona.occupation || '', + color: nameToCSS(persona.name), + screenX: s.x * 100, + screenY: s.y * 100, }); }); setNameTags(tags); }, [messages, personas, projectToScreen]); - // Main Three.js setup + // ── Main Three.js setup ────────────────────────────────────────────────────── + useEffect(() => { const container = mountRef.current; if (!container || personas.length === 0) return; - const width = container.clientWidth; - const height = CANVAS_HEIGHT; + const W = container.clientWidth; + const H = CANVAS_HEIGHT; - // Scene const scene = new THREE.Scene(); - scene.background = new THREE.Color(0x080605); - scene.fog = new THREE.FogExp2(0x0a0705, 0.04); - sceneRef.current = scene; + scene.background = new THREE.Color(0x060503); + scene.fog = new THREE.FogExp2(0x080604, 0.03); - // Camera — higher, more dramatic angle - const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); - camera.position.set(0, 7, 6); - camera.lookAt(0, 0, 0); + const camera = new THREE.PerspectiveCamera(44, W / H, 0.1, 100); + camera.position.set(0, 7.5, 7); + camera.lookAt(0, 0.2, 0); cameraRef.current = camera; - // Renderer const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); - renderer.setSize(width, height); + renderer.setSize(W, H); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; - renderer.toneMappingExposure = 1.1; + renderer.toneMappingExposure = 1.2; container.appendChild(renderer.domElement); rendererRef.current = renderer; // ── Lighting ────────────────────────────────────────────────────────────── - const ambientLight = new THREE.AmbientLight(0x1a1208, 0.6); - scene.add(ambientLight); + scene.add(new THREE.AmbientLight(0x1a1208, 0.7)); - const dirLight = new THREE.DirectionalLight(0xfff5e4, 1.8); - dirLight.position.set(3, 8, 5); + const dirLight = new THREE.DirectionalLight(0xfff5e0, 2.1); + dirLight.position.set(4, 9, 6); dirLight.castShadow = true; dirLight.shadow.mapSize.width = 1024; dirLight.shadow.mapSize.height = 1024; - dirLight.shadow.camera.near = 0.5; - dirLight.shadow.camera.far = 30; scene.add(dirLight); - // Amber center glow - const centerLight = new THREE.PointLight(0xfbbf24, 0.8, 6); - centerLight.position.set(0, 2, 0); + const centerLight = new THREE.PointLight(0xfbbf24, 1.1, 8); + centerLight.position.set(0, 2.5, 0); scene.add(centerLight); - // Hemisphere — warm sky / dark ground - const hemiLight = new THREE.HemisphereLight(0xfbbf24, 0x0c0a09, 0.3); - scene.add(hemiLight); + scene.add(new THREE.HemisphereLight(0xfbbf24, 0x0c0a09, 0.35)); - // ── Table surface ───────────────────────────────────────────────────────── + // ── Table ───────────────────────────────────────────────────────────────── const tableTexture = track(makeTableTexture()); - tableTexture.wrapS = THREE.RepeatWrapping; - tableTexture.wrapT = THREE.RepeatWrapping; - - const tableGeo = track(new THREE.CylinderGeometry(2.4, 2.2, 0.12, 64)); + const tableGeo = track(new THREE.CylinderGeometry(2.5, 2.3, 0.11, 72)); const tableMat = track(new THREE.MeshStandardMaterial({ - map: tableTexture, - roughness: 0.7, - metalness: 0.15, + map: tableTexture, roughness: 0.5, metalness: 0.28, })); - const table = new THREE.Mesh(tableGeo, tableMat); - table.receiveShadow = true; - table.position.y = 0; - scene.add(table); + const tableMesh = new THREE.Mesh(tableGeo, tableMat); + tableMesh.receiveShadow = true; + scene.add(tableMesh); - // ── Amber rim ───────────────────────────────────────────────────────────── - const rimGeo = track(new THREE.TorusGeometry(2.4, 0.06, 16, 64)); + // Outer amber rim + const rimGeo = track(new THREE.TorusGeometry(2.5, 0.065, 16, 80)); const rimMat = track(new THREE.MeshStandardMaterial({ - color: 0xfbbf24, - emissive: 0xfbbf24, - emissiveIntensity: 0.8, - roughness: 0.2, - metalness: 0.4, + color: 0xfbbf24, emissive: 0xfbbf24, emissiveIntensity: 1.0, + roughness: 0.12, metalness: 0.5, })); - const rim = new THREE.Mesh(rimGeo, rimMat); - rim.rotation.x = Math.PI / 2; - rim.position.y = 0.062; - scene.add(rim); + const rimMesh = new THREE.Mesh(rimGeo, rimMat); + rimMesh.rotation.x = Math.PI / 2; + rimMesh.position.y = 0.056; + scene.add(rimMesh); - // Thin inner rim - const innerRimGeo = track(new THREE.TorusGeometry(2.35, 0.025, 8, 64)); + // Inner rim + const innerRimGeo = track(new THREE.TorusGeometry(2.4, 0.022, 8, 72)); const innerRimMat = track(new THREE.MeshStandardMaterial({ - color: 0xfbbf24, - emissive: 0xfbbf24, - emissiveIntensity: 0.3, - roughness: 0.4, + color: 0xfbbf24, emissive: 0xfbbf24, emissiveIntensity: 0.3, + roughness: 0.4, transparent: true, opacity: 0.65, })); const innerRim = new THREE.Mesh(innerRimGeo, innerRimMat); innerRim.rotation.x = Math.PI / 2; - innerRim.position.y = 0.058; + innerRim.position.y = 0.053; scene.add(innerRim); - // ── Center emblem ───────────────────────────────────────────────────────── - const emblemTexture = track(makeEmblemTexture()); - const emblemGeo = track(new THREE.CylinderGeometry(0.25, 0.25, 0.001, 32)); + // Rotating center emblem + const emblemGeo = track(new THREE.CylinderGeometry(0.32, 0.32, 0.001, 6)); const emblemMat = track(new THREE.MeshStandardMaterial({ - map: emblemTexture, - emissive: new THREE.Color(0xfbbf24), - emissiveIntensity: 0.6, - transparent: true, - roughness: 0.3, - metalness: 0.2, + color: 0xfbbf24, emissive: 0xfbbf24, emissiveIntensity: 0.55, + roughness: 0.18, metalness: 0.35, transparent: true, opacity: 0.85, })); const emblem = new THREE.Mesh(emblemGeo, emblemMat); - emblem.position.y = 0.062; + emblem.position.y = 0.057; scene.add(emblem); + emblemRef.current = emblem; // ── Floor ───────────────────────────────────────────────────────────────── - const floorTexture = track(makeFloorTexture()); - floorTexture.wrapS = THREE.RepeatWrapping; - floorTexture.wrapT = THREE.RepeatWrapping; - floorTexture.repeat.set(3, 3); - - const floorGeo = track(new THREE.PlaneGeometry(20, 20)); + const floorGeo = track(new THREE.PlaneGeometry(26, 26)); const floorMat = track(new THREE.MeshStandardMaterial({ - map: floorTexture, - color: 0x0a0806, - roughness: 0.9, - metalness: 0.1, + color: 0x080604, roughness: 0.94, metalness: 0.06, })); const floor = new THREE.Mesh(floorGeo, floorMat); floor.rotation.x = -Math.PI / 2; - floor.position.y = -0.1; + floor.position.y = -0.12; floor.receiveShadow = true; scene.add(floor); + // ── Ambient particles ───────────────────────────────────────────────────── + const PC = 80; + const pPos = new Float32Array(PC * 3); + const pVel = new Float32Array(PC); + for (let i = 0; i < PC; i++) { + const a = Math.random() * Math.PI * 2; + const r = 0.7 + Math.random() * 3.2; + pPos[i * 3] = r * Math.cos(a); + pPos[i * 3 + 1] = Math.random() * 2.8 - 0.15; + pPos[i * 3 + 2] = r * Math.sin(a); + pVel[i] = 0.0014 + Math.random() * 0.0028; + } + const pGeo = track(new THREE.BufferGeometry()); + pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); + const pMat = track(new THREE.PointsMaterial({ + color: 0xfbbf24, size: 0.044, transparent: true, opacity: 0.38, sizeAttenuation: true, + })); + const particles = new THREE.Points(pGeo, pMat); + scene.add(particles); + particlesDataRef.current = { points: particles, pos: pPos, vel: pVel, count: PC }; + // ── Avatars ─────────────────────────────────────────────────────────────── const count = personas.length; - const orbitRadius = 2.85; + const ORBIT_R = 3.05; const newAvatars = new Map(); personas.forEach((persona, index) => { const angle = (2 * Math.PI / count) * index - Math.PI / 2; - const x = orbitRadius * Math.cos(angle); - const z = orbitRadius * Math.sin(angle); - - const color = nameToColor(persona.name); + const x = ORBIT_R * Math.cos(angle); + const z = ORBIT_R * Math.sin(angle); + const hue = nameToHue(persona.name); const hexColor = nameToHex(persona.name); const initial = persona.name.charAt(0).toUpperCase(); - // Avatar sphere — larger, higher-res texture - const avatarGeo = track(new THREE.SphereGeometry(0.42, 32, 32)); - const texture = track(makeAvatarTexture(initial, color)); - const avatarMat = track(new THREE.MeshStandardMaterial({ - map: texture, - roughness: 0.5, - metalness: 0.15, - })); - const avatarMesh = new THREE.Mesh(avatarGeo, avatarMat); - avatarMesh.position.set(x, 0.5, z); - avatarMesh.castShadow = true; - scene.add(avatarMesh); + // Person group + const group = new THREE.Group(); + group.position.set(x, 0, z); - // Amber halo ring (active speaker only) - const ringGeo = track(new THREE.TorusGeometry(0.52, 0.04, 8, 32)); - const ringMat = track(new THREE.MeshStandardMaterial({ - color: 0xfbbf24, - emissive: 0xfbbf24, - emissiveIntensity: 0.9, - transparent: true, - opacity: 0, + // Body — tapered suit/coat cylinder + const bodyGeo = track(new THREE.CylinderGeometry(0.16, 0.27, 0.55, 10)); + const bodyDark = new THREE.Color(hexColor).multiplyScalar(0.2); + const bodyMat = track(new THREE.MeshStandardMaterial({ + color: bodyDark, + emissive: new THREE.Color(hexColor), + emissiveIntensity: 0.07, + roughness: 0.78, + metalness: 0.06, })); - const ringMesh = new THREE.Mesh(ringGeo, ringMat); - ringMesh.position.set(x, 0.5, z); - scene.add(ringMesh); + const bodyMesh = new THREE.Mesh(bodyGeo, bodyMat); + bodyMesh.position.y = 0.275; + bodyMesh.castShadow = true; + group.add(bodyMesh); - // Active speaker amber point light - const haloLight = new THREE.PointLight(0xfbbf24, 0, 4); - haloLight.position.set(x, 1.5, z); + // Neck + const neckGeo = track(new THREE.CylinderGeometry(0.076, 0.09, 0.13, 8)); + const neckMat = track(new THREE.MeshStandardMaterial({ color: 0xc2916a, roughness: 0.65 })); + const neckMesh = new THREE.Mesh(neckGeo, neckMat); + neckMesh.position.y = 0.55 + 0.065; + group.add(neckMesh); + + // Head + const headGeo = track(new THREE.SphereGeometry(0.3, 28, 28)); + const headTex = track(makeHeadTexture(initial, hue)); + const headMat = track(new THREE.MeshStandardMaterial({ + map: headTex, roughness: 0.45, metalness: 0.1, + })); + const headMesh = new THREE.Mesh(headGeo, headMat); + headMesh.position.y = 0.55 + 0.13 + 0.3; + headMesh.castShadow = true; + group.add(headMesh); + + scene.add(group); + + // ── Glow pad ──────────────────────────────────────────────────────────── + const glowRingGeo = track(new THREE.TorusGeometry(0.56, 0.04, 8, 52)); + const glowRingMat = track(new THREE.MeshStandardMaterial({ + color: hexColor, + emissive: new THREE.Color(hexColor), + emissiveIntensity: 0.7, + transparent: true, opacity: 0.8, roughness: 0.2, + })); + const glowRingMesh = new THREE.Mesh(glowRingGeo, glowRingMat); + glowRingMesh.rotation.x = Math.PI / 2; + glowRingMesh.position.set(x, -0.115, z); + scene.add(glowRingMesh); + + const discGeo = track(new THREE.CircleGeometry(0.54, 48)); + const glowDiscMat = track(new THREE.MeshStandardMaterial({ + color: hexColor, + emissive: new THREE.Color(hexColor), + emissiveIntensity: 0.2, + transparent: true, opacity: 0.1, roughness: 0.4, side: THREE.DoubleSide, + })); + const discMesh = new THREE.Mesh(discGeo, glowDiscMat); + discMesh.rotation.x = -Math.PI / 2; + discMesh.position.set(x, -0.118, z); + scene.add(discMesh); + + // ── Expanding pulse rings (3 staggered) ───────────────────────────────── + const pulseRings: PulseRing[] = []; + for (let pr = 0; pr < 3; pr++) { + const prGeo = track(new THREE.TorusGeometry(0.56, 0.025, 8, 48)); + const prMat = track(new THREE.MeshStandardMaterial({ + color: hexColor, + emissive: new THREE.Color(hexColor), + emissiveIntensity: 0.85, + transparent: true, opacity: 0, roughness: 0.2, + })); + const prMesh = new THREE.Mesh(prGeo, prMat); + prMesh.rotation.x = Math.PI / 2; + prMesh.position.set(x, -0.1, z); + scene.add(prMesh); + pulseRings.push({ mesh: prMesh, mat: prMat, phase: pr / 3 }); + } + + // ── Lights ─────────────────────────────────────────────────────────────── + const haloLight = new THREE.PointLight(hexColor, 0, 5.5); + haloLight.position.set(x, 2.2, z); scene.add(haloLight); - // Inactive persona soft colored light - const inactiveLight = new THREE.PointLight(hexColor, 0.2, 2.5); - inactiveLight.position.set(x, 1.0, z); - scene.add(inactiveLight); - - // SpotLight from above for each avatar - const spotLight = new THREE.SpotLight(0xfff5e4, 0.6, 5, Math.PI / 8, 0.4, 1); - spotLight.position.set(x, 3, z); + const spotLight = new THREE.SpotLight(0xfff5e4, 0.5, 6, Math.PI / 9, 0.45, 1); + spotLight.position.set(x, 3.5, z); spotLight.target.position.set(x, 0, z); scene.add(spotLight); scene.add(spotLight.target); + // ── Connection line to table center ───────────────────────────────────── + const linePoints = [ + new THREE.Vector3(0, 0.07, 0), + new THREE.Vector3(x * 0.97, 0.08, z * 0.97), + ]; + const lineGeo = track(new THREE.BufferGeometry().setFromPoints(linePoints)); + const connLineMat = track(new THREE.LineBasicMaterial({ + color: hexColor, transparent: true, opacity: 0, + })); + const connLine = new THREE.Line(lineGeo, connLineMat); + scene.add(connLine); + const personaId = persona._id || persona.id; newAvatars.set(personaId, { - mesh: avatarMesh, - ring: ringMesh, + group, + headMesh, + glowRingMat, + glowDiscMat, + pulseRings, haloLight, - inactiveLight, spotLight, + connLineMat, targetScale: 1, currentScale: 1, + targetY: 0, + currentY: 0, + isActive: false, + bobPhase: index * (Math.PI * 2 / count), }); }); @@ -504,24 +530,59 @@ export default function RoundTable3D({ personas, activeSpeakerId, messages }: Ro // ── Animation loop ──────────────────────────────────────────────────────── const animate = () => { animFrameRef.current = requestAnimationFrame(animate); + timeRef.current += 0.016; + const t = timeRef.current; if (!reducedMotionRef.current) { - cameraAngleRef.current += 0.0005; + cameraAngleRef.current += 0.00042; + } + const camA = cameraAngleRef.current; + camera.position.x = 8.8 * Math.sin(camA); + camera.position.z = 8.8 * Math.cos(camA) * 0.76 + 1.4; + camera.position.y = 7.5 + Math.sin(t * 0.07) * 0.14; + camera.lookAt(0, 0.2, 0); + + // Rotate emblem + if (emblemRef.current) emblemRef.current.rotation.y = t * 0.28; + + // Drift particles upward + const pd = particlesDataRef.current; + if (pd) { + for (let i = 0; i < pd.count; i++) { + pd.pos[i * 3 + 1] += pd.vel[i]; + if (pd.pos[i * 3 + 1] > 3.0) pd.pos[i * 3 + 1] = -0.15; + } + (pd.points.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true; } - const r = 8.5; - const camAngle = cameraAngleRef.current; - camera.position.x = r * Math.sin(camAngle); - camera.position.z = r * Math.cos(camAngle) * 0.8 + 1.5; - camera.position.y = 7; - camera.lookAt(0, 0, 0); // Update avatars - avatarsRef.current.forEach((avatarObj) => { - avatarObj.currentScale += (avatarObj.targetScale - avatarObj.currentScale) * 0.07; - avatarObj.mesh.scale.setScalar(avatarObj.currentScale); + avatarsRef.current.forEach((av) => { + // Smooth interpolation + av.currentScale += (av.targetScale - av.currentScale) * 0.07; + av.currentY += (av.targetY - av.currentY) * 0.065; + av.group.scale.setScalar(av.currentScale); - if (avatarObj.targetScale > 1.1) { - avatarObj.ring.rotation.z += 0.04; + // Gentle passive bob + if (!av.isActive) { + av.group.position.y = Math.sin(t * 0.38 + av.bobPhase) * 0.022; + } else { + av.group.position.y = av.currentY + Math.sin(t * 1.1) * 0.015; + } + + // Expanding pulse rings + av.pulseRings.forEach(pr => { + if (av.isActive) { + pr.phase = (pr.phase + 0.006) % 1; + pr.mesh.scale.setScalar(1 + pr.phase * 3.0); + pr.mat.opacity = (1 - pr.phase) * 0.5; + } else { + pr.mat.opacity = 0; + } + }); + + // Pulsing connection line + if (av.isActive) { + av.connLineMat.opacity = 0.28 + Math.sin(t * 3.5) * 0.14; } }); @@ -529,46 +590,49 @@ export default function RoundTable3D({ personas, activeSpeakerId, messages }: Ro }; animate(); - // ── ResizeObserver ──────────────────────────────────────────────────────── - const resizeObserver = new ResizeObserver(() => { + // ── Resize ──────────────────────────────────────────────────────────────── + const ro = new ResizeObserver(() => { if (!container || !rendererRef.current || !cameraRef.current) return; const w = container.clientWidth; rendererRef.current.setSize(w, CANVAS_HEIGHT); cameraRef.current.aspect = w / CANVAS_HEIGHT; cameraRef.current.updateProjectionMatrix(); }); - resizeObserver.observe(container); + ro.observe(container); // ── Cleanup ─────────────────────────────────────────────────────────────── return () => { cancelAnimationFrame(animFrameRef.current); - resizeObserver.disconnect(); - disposablesRef.current.forEach((obj) => obj.dispose()); + ro.disconnect(); + disposablesRef.current.forEach(obj => { + if (obj && typeof obj.dispose === 'function') obj.dispose(); + }); disposablesRef.current = []; avatarsRef.current.clear(); renderer.dispose(); - if (container.contains(renderer.domElement)) { - container.removeChild(renderer.domElement); - } - sceneRef.current = null; + if (container.contains(renderer.domElement)) container.removeChild(renderer.domElement); cameraRef.current = null; rendererRef.current = null; + particlesDataRef.current = null; + emblemRef.current = null; }; - // personas rebuild → recreate scene // eslint-disable-next-line react-hooks/exhaustive-deps }, [personas]); - // ── Active speaker glow ──────────────────────────────────────────────────── + // ── Active speaker ───────────────────────────────────────────────────────── useEffect(() => { - avatarsRef.current.forEach((avatarObj, id) => { - const isSpeaking = id === activeSpeakerId; - avatarObj.targetScale = isSpeaking ? 1.25 : 1.0; - avatarObj.haloLight.intensity = isSpeaking ? 3 : 0; - avatarObj.inactiveLight.intensity = isSpeaking ? 0 : 0.2; - - const ringMat = avatarObj.ring.material as THREE.MeshStandardMaterial; - ringMat.opacity = isSpeaking ? 0.9 : 0; - ringMat.needsUpdate = true; + avatarsRef.current.forEach((av, id) => { + const active = id === activeSpeakerId; + av.isActive = active; + av.targetScale = active ? 1.18 : 1.0; + av.targetY = active ? 0.14 : 0; + av.haloLight.intensity = active ? 2.8 : 0; + av.glowRingMat.emissiveIntensity = active ? 1.3 : 0.7; + av.glowRingMat.opacity = active ? 1.0 : 0.8; + av.glowDiscMat.opacity = active ? 0.22 : 0.1; + av.connLineMat.opacity = active ? 0.28 : 0; + av.spotLight.intensity = active ? 1.3 : 0.5; + if (!active) av.pulseRings.forEach(pr => { pr.mat.opacity = 0; }); }); }, [activeSpeakerId]); @@ -576,7 +640,7 @@ export default function RoundTable3D({ personas, activeSpeakerId, messages }: Ro const [tick, setTick] = useState(0); useEffect(() => { if (personas.length === 0) return; - const id = setInterval(() => setTick((t) => t + 1), 200); + const id = setInterval(() => setTick(t => t + 1), 200); return () => clearInterval(id); }, [personas.length]); @@ -585,60 +649,54 @@ export default function RoundTable3D({ personas, activeSpeakerId, messages }: Ro // eslint-disable-next-line react-hooks/exhaustive-deps }, [tick, messages]); + // ── Render ───────────────────────────────────────────────────────────────── return (
- {/* Three.js canvas mount */}
- {/* Speech bubble overlay */} - {speechBubbles.map((bubble) => ( + {/* Speech bubbles */} + {speechBubbles.map(bubble => (
-
+
{bubble.text}
- {/* Tail */} -
+
))} - {/* Floating name tags */} - {nameTags.map((tag) => ( + {/* Name tags with occupation pill */} + {nameTags.map(tag => (
- {tag.name} +
+
+ + {tag.name} + + {tag.occupation && ( + + · {tag.occupation} + + )} +
))}
diff --git a/src/pages/FocusGroupSession.tsx b/src/pages/FocusGroupSession.tsx index 4b690305..10209f7e 100755 --- a/src/pages/FocusGroupSession.tsx +++ b/src/pages/FocusGroupSession.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAuth } from '@/contexts/AuthContext'; import { @@ -38,6 +38,7 @@ import { useQuery } from '@tanstack/react-query'; import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; import { getSocket } from '@/services/websocketServiceNew'; import ProgressModal from '@/components/ui/ProgressModal'; +import RoundTable3D from '@/components/focus-group-session/RoundTable3D'; // GPT-5 FIX: Use new singleton WebSocket service import { initSocket, joinFocusGroup, leaveFocusGroup } from '@/services/websocketServiceNew'; import { @@ -59,6 +60,7 @@ const FocusGroupSession = () => { const [focusGroup, setFocusGroup] = useState(null); const [participants, setParticipants] = useState([]); const [allPersonas, setAllPersonas] = useState([]); + const [showTable, setShowTable] = useState(true); const [activeTab, setActiveTab] = useState('chat'); const [moderatorStatus, setModeratorStatus] = useState(null); const [showAutonomousDashboard, setShowAutonomousDashboard] = useState(false); @@ -738,19 +740,19 @@ const FocusGroupSession = () => { id: data._id || data.id, name: data.name, status: data.status || 'in-progress', - participants: data.participants || [], + participants: data.personas || data.participants || [], date: data.date || new Date().toISOString(), duration: data.duration || 60, topic: data.topic || 'general', discussionGuide: data.discussionGuide || '', llm_model: data.llm_model || 'gpt-5.4' }; - + setFocusGroup(focusGroupData); setSelectedModel(focusGroupData.llm_model || 'gpt-5.4'); setSelectedReasoningEffort(focusGroupData.reasoning_effort || 'medium'); setSelectedVerbosity(focusGroupData.verbosity || 'medium'); - + // Handle participants if (data.participants_data && Array.isArray(data.participants_data)) { // Use participants data if available @@ -767,7 +769,7 @@ const FocusGroupSession = () => { setParticipants(groupParticipants); } - + // Load messages and moderator status await fetchMessages(); await fetchModeratorStatus(); @@ -860,7 +862,7 @@ const FocusGroupSession = () => { id: data._id || data.id, name: data.name, status: data.status || 'in-progress', - participants: data.participants || [], + participants: data.personas || data.participants || [], date: data.date || new Date().toISOString(), duration: data.duration || 60, topic: data.topic || 'general', @@ -1777,6 +1779,17 @@ const FocusGroupSession = () => { }); }; + // Derive who spoke last (for 3D table highlight) — must be before any conditional returns + const activeSpeakerId = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.senderId && msg.senderId !== 'moderator' && msg.senderId !== 'facilitator') { + return msg.senderId; + } + } + return null; + }, [messages]); + // Show loading state while fetching if (isLoading) { return ( @@ -1810,7 +1823,7 @@ const FocusGroupSession = () => {
); } - + return (
@@ -1818,21 +1831,21 @@ const FocusGroupSession = () => { {/* WebSocket Connection Status Bar */} {useWebSocketEnabled && isStatusBarVisible && (
{wsConnected @@ -1870,10 +1883,10 @@ const FocusGroupSession = () => { onClick={() => setIsStatusBarVisible(true)} className={`px-3 py-1 rounded-full text-white text-xs font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${ wsConnected - ? 'bg-green-500 hover:bg-green-600' - : wsConnecting - ? 'bg-yellow-500 hover:bg-yellow-600' - : 'bg-red-500 hover:bg-red-600' + ? 'bg-brand-success/80 hover:bg-brand-success' + : wsConnecting + ? 'bg-primary/80 hover:bg-primary' + : 'bg-destructive/80 hover:bg-destructive' }`} title={ wsConnected @@ -1884,7 +1897,7 @@ const FocusGroupSession = () => { } >
-
+
{wsConnected ? 'Live' : wsConnecting ? 'Connecting' : 'Offline'}
@@ -1946,25 +1959,25 @@ const FocusGroupSession = () => { {/* Quota warning banner */} {quotaWarning && ( -
+
- + Usage at {Math.round(quotaWarning.pct * 100)}% of {quotaWarning.scope} quota (${quotaWarning.used_usd.toFixed(4)} of ${quotaWarning.limit_usd.toFixed(2)})
- +
)} {/* Quota exceeded banner */} {quotaExceeded && ( -
- +
+ Quota exceeded ({quotaExceeded.scope}): ${quotaExceeded.used_usd.toFixed(4)} of ${quotaExceeded.limit_usd.toFixed(2)} used. - +
)} @@ -1991,8 +2004,8 @@ const FocusGroupSession = () => {

{focusGroup.name}

{isAiModeActive && ( - - + + Live )} @@ -2015,13 +2028,43 @@ const FocusGroupSession = () => { />
- {/* CENTER: Discussion */} + {/* CENTER: Round Table + Discussion */}
+ + {/* 3D Round Table */} + {participants.length > 0 && showTable && ( +
+ + +
+ )} + {participants.length > 0 && !showTable && ( + + )} + {messages.length === 0 ? ( -
-

- No messages yet. Start the session to begin the discussion. -

+
+ {participants.length === 0 && ( +

+ No messages yet. Start the session to begin the discussion. +

+ )}