diff --git a/client/src/components/Echart/somaticField3D/SomaticFieldDemo.tsx b/client/src/components/Echart/somaticField3D/SomaticFieldDemo.tsx new file mode 100644 index 00000000..fe4b5575 --- /dev/null +++ b/client/src/components/Echart/somaticField3D/SomaticFieldDemo.tsx @@ -0,0 +1,189 @@ +import { useEffect, useRef } from "react"; +import { init, ECharts } from "echarts"; +import "echarts-gl"; +import { useSystem } from "context/system"; +import { buildBodyPointCloud } from "./bodyModel"; +import { RegionName, somaticCoord } from "./somaticMap"; + +/** + * Self-contained, zero-data showcase of the Somatic Field for public surfaces + * (splash page). Synthetic experiences arise and pass through the body on a + * seamless loop while the camera slowly orbits. No auth, no GraphQL, no + * controls — drop it anywhere: + * + * + */ + +type DemoEvent = { + t0: number; + dur: number; + coord: [number, number, number]; + rgb: string; +}; + +const LOOP_S = 36; +const RAMP_S = 0.5; +const FADE_S = 3.2; + +const POSITIVE = "52,211,153"; +const NEGATIVE = "248,113,113"; +const NEUTRAL = "148,163,184"; + +// A hand-shaped phenomenological loop: breath at the nostrils, a thought +// stream in the head, feeling tones moving through chest and belly. +const SCRIPT: { + region: RegionName; + rgb: string; + period: number; + dur: number; + phase: number; +}[] = [ + { region: "Nostrils", rgb: NEUTRAL, period: 4.5, dur: 2.2, phase: 0 }, + { region: "Head", rgb: NEUTRAL, period: 6, dur: 1.6, phase: 1.2 }, + { region: "Head", rgb: NEGATIVE, period: 13, dur: 2.2, phase: 7 }, + { region: "Heart", rgb: POSITIVE, period: 9, dur: 2.8, phase: 3.5 }, + { region: "Chest", rgb: NEGATIVE, period: 17, dur: 2.4, phase: 10 }, + { region: "Solar Plexus", rgb: NEGATIVE, period: 15, dur: 2, phase: 5.5 }, + { region: "Stomach", rgb: NEUTRAL, period: 11, dur: 1.8, phase: 8.2 }, + { region: "Throat", rgb: NEUTRAL, period: 19, dur: 1.5, phase: 13 }, + { region: "Hands", rgb: POSITIVE, period: 14, dur: 2, phase: 6.4 }, + { region: "Legs", rgb: NEUTRAL, period: 21, dur: 1.7, phase: 15.5 }, + { region: "Whole Body", rgb: POSITIVE, period: 18, dur: 3.5, phase: 11.3 }, +]; + +const buildDemoEvents = (): DemoEvent[] => { + const events: DemoEvent[] = []; + let seed = 1; + for (const item of SCRIPT) { + for (let t = item.phase; t < LOOP_S; t += item.period) { + events.push({ + t0: t, + dur: item.dur, + coord: somaticCoord(item.region, seed++), + rgb: item.rgb, + }); + } + } + return events.sort((a, b) => a.t0 - b.t0); +}; + +const easeOutCubic = (k: number) => 1 - Math.pow(1 - k, 3); + +const AXIS_OFF = { + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false }, +}; + +type SomaticFieldDemoProps = { + height?: string; + className?: string; +}; + +const SomaticFieldDemo = ({ + height = "480px", + className = "", +}: SomaticFieldDemoProps) => { + const { darkMode } = useSystem(); + const containerRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const chart = init(el, darkMode ? "dark" : "light", { renderer: "canvas" }); + chartRef.current = chart; + chart.setOption({ + backgroundColor: "transparent", + animation: false, + xAxis3D: { type: "value", min: -32, max: 32, ...AXIS_OFF }, + yAxis3D: { type: "value", min: -32, max: 32, ...AXIS_OFF }, + zAxis3D: { type: "value", min: -2, max: 104, ...AXIS_OFF }, + grid3D: { + show: false, + boxWidth: 64, + boxDepth: 64, + boxHeight: 106, + viewControl: { + distance: 200, + alpha: 5, + beta: 0, + autoRotate: true, + autoRotateSpeed: 6, + zoomSensitivity: 0, + panSensitivity: 0, + }, + light: { + main: { intensity: 1.1, shadow: false }, + ambient: { intensity: 0.55 }, + }, + }, + series: [ + { + id: "body", + type: "scatter3D", + silent: true, + data: buildBodyPointCloud().map((p) => ({ value: p })), + symbolSize: 1.7, + itemStyle: { + color: darkMode ? "rgba(148,163,184,0.30)" : "rgba(100,116,139,0.26)", + }, + }, + { id: "events", type: "scatter3D", silent: true, data: [] }, + ], + }); + + const events = buildDemoEvents(); + const start = performance.now(); + let raf = requestAnimationFrame(function step(now: number) { + const t = ((now - start) / 1000) % LOOP_S; + const data: { + value: [number, number, number]; + symbolSize: number; + itemStyle: { color: string }; + }[] = []; + for (const e of events) { + // Wrap-around so events straddling the loop boundary fade smoothly. + let age = t - e.t0; + if (age < 0) age += LOOP_S; + if (age > e.dur + FADE_S) continue; + const baseSize = 8 + 3 * e.dur; + let scale: number; + let alpha: number; + if (age < RAMP_S) { + const k = easeOutCubic(age / RAMP_S); + scale = k; + alpha = 0.9 * k; + } else if (age <= e.dur) { + scale = 1; + alpha = 0.9; + } else { + const k = (age - e.dur) / FADE_S; + scale = 1 - 0.7 * k; + alpha = 0.9 * (1 - k); + } + data.push({ + value: e.coord, + symbolSize: baseSize * scale, + itemStyle: { color: `rgba(${e.rgb},${alpha.toFixed(3)})` }, + }); + } + chart.setOption({ series: [{ id: "events", data }] }); + raf = requestAnimationFrame(step); + }); + + const resizeObserver = new ResizeObserver(() => chart.resize()); + resizeObserver.observe(el); + return () => { + cancelAnimationFrame(raf); + resizeObserver.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + }, [darkMode]); + + return
; +}; + +export default SomaticFieldDemo; diff --git a/client/src/components/Echart/somaticField3D/bodyModel.ts b/client/src/components/Echart/somaticField3D/bodyModel.ts new file mode 100644 index 00000000..2484ce0e --- /dev/null +++ b/client/src/components/Echart/somaticField3D/bodyModel.ts @@ -0,0 +1,109 @@ +/** + * Parametric 3D point-cloud human figure for the Somatic Field visualization. + * + * Same coordinate system as somaticMap.ts: z = height (0 feet → 100 crown), + * x = lateral, y = depth (positive = front). The figure is built from spheres + * and tapered capsules, sampled with a seeded PRNG so the cloud is identical + * on every render (stable point identity keeps the GL scene calm). + */ + +type Point = [number, number, number]; + +// mulberry32 — tiny deterministic PRNG. +const makeRng = (seed: number) => { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +/** Points on a sphere surface, optionally squashed per axis. */ +const sphere = ( + rng: () => number, + center: Point, + r: number, + n: number, + scale: Point = [1, 1, 1] +): Point[] => { + const pts: Point[] = []; + for (let i = 0; i < n; i++) { + const theta = rng() * Math.PI * 2; + const phi = Math.acos(rng() * 2 - 1); + pts.push([ + center[0] + r * scale[0] * Math.sin(phi) * Math.cos(theta), + center[1] + r * scale[1] * Math.sin(phi) * Math.sin(theta), + center[2] + r * scale[2] * Math.cos(phi), + ]); + } + return pts; +}; + +/** + * Points on the lateral surface of a tapered capsule from p1 (radius r1) to + * p2 (radius r2). yScale flattens the cross-section front-to-back so the + * torso reads as a body rather than a tube. + */ +const capsule = ( + rng: () => number, + p1: Point, + p2: Point, + r1: number, + r2: number, + n: number, + yScale = 1 +): Point[] => { + const pts: Point[] = []; + for (let i = 0; i < n; i++) { + const t = rng(); + const r = r1 + (r2 - r1) * t; + const theta = rng() * Math.PI * 2; + pts.push([ + p1[0] + (p2[0] - p1[0]) * t + r * Math.cos(theta), + p1[1] + (p2[1] - p1[1]) * t + r * yScale * Math.sin(theta), + p1[2] + (p2[2] - p1[2]) * t, + ]); + } + return pts; +}; + +const mirrorX = (pts: Point[]): Point[] => pts.map(([x, y, z]) => [-x, y, z]); + +let cache: Point[] | null = null; + +/** The standing figure, ~2,600 surface points. Memoized — pure geometry. */ +export const buildBodyPointCloud = (): Point[] => { + if (cache) return cache; + const rng = makeRng(42); + const pts: Point[] = []; + + // Head + neck + pts.push(...sphere(rng, [0, 0.5, 90], 6.2, 320, [1, 1.1, 1.15])); + pts.push(...capsule(rng, [0, 0, 86], [0, 0, 81.5], 2.6, 3.2, 70)); + + // Torso (shoulders → waist) and pelvis, flattened front-to-back + pts.push(...capsule(rng, [0, 0, 79.5], [0, 0, 51], 10.5, 8.2, 700, 0.55)); + pts.push(...capsule(rng, [0, 0, 51], [0, 0, 44.5], 8.4, 7.2, 160, 0.62)); + + // Right arm (upper, forearm, hand) then mirrored left + const rightArm: Point[] = [ + ...capsule(rng, [11.5, 0, 78.5], [14, 1, 62], 2.9, 2.4, 150), + ...capsule(rng, [14, 1, 62], [16.5, 3, 45], 2.3, 1.9, 130), + ...sphere(rng, [17, 4, 42], 2, 45, [1, 1.4, 1.6]), + ]; + pts.push(...rightArm, ...mirrorX(rightArm)); + + // Right leg (thigh, shin, foot) then mirrored left + const rightLeg: Point[] = [ + ...capsule(rng, [4.8, 0, 46], [6, 1, 26], 4.2, 3, 200), + ...capsule(rng, [6, 1, 26], [6.5, 2, 5], 3, 2.2, 170), + ...capsule(rng, [6.5, 2, 1.8], [7, 8, 1.8], 2, 1.5, 55), + ]; + pts.push(...rightLeg, ...mirrorX(rightLeg)); + + cache = pts; + return pts; +}; diff --git a/client/src/components/Echart/somaticField3D/index.tsx b/client/src/components/Echart/somaticField3D/index.tsx new file mode 100644 index 00000000..1b10aad1 --- /dev/null +++ b/client/src/components/Echart/somaticField3D/index.tsx @@ -0,0 +1,370 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { init, ECharts } from "echarts"; +import "echarts-gl"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import PauseIcon from "@mui/icons-material/Pause"; +import ReplayIcon from "@mui/icons-material/Replay"; +import { ManifestationOutput, SessionOutput, ValenceValueEnum } from "gql/graphql"; +import { RoundButton, Select, Text } from "components/ui"; +import type { SelectOption } from "components/ui"; +import { useSystem } from "context/system"; +import { buildBodyPointCloud } from "./bodyModel"; +import { + RegionName, + SomaticResolution, + resolveSomaticLocation, + somaticCoord, +} from "./somaticMap"; + +/** + * Somatic Field — a 3D body through which the session's experiences arise and + * pass in time. Each manifestation ignites at its somatic location (resolved + * from structured Body Location reports, free-text parsing, or its experience + * type's default region), holds for its input duration, then dissolves, + * leaving a faint residue. Colour = valence; size = input duration. + */ + +type SomaticEvent = { + /** Seconds from session start when the experience begins. */ + t0: number; + /** Input duration in seconds (clamped). */ + dur: number; + coord: [number, number, number]; + rgb: string; + label: string; + region: RegionName; + type: string; + source: SomaticResolution["source"]; +}; + +const VALENCE_RGB: Record = { + [ValenceValueEnum.Positive]: "52,211,153", + [ValenceValueEnum.Negative]: "248,113,113", + [ValenceValueEnum.Neutral]: "148,163,184", +}; +const DEFAULT_RGB = "129,140,248"; + +const RAMP_S = 0.35; +const FADE_S = 2.5; +const MIN_DUR_S = 1.2; +const MAX_DUR_S = 60; + +const SPEED_OPTIONS: SelectOption[] = [1, 2, 4, 8, 16, 32].map((s) => ({ + value: String(s), + label: `${s}×`, +})); + +const easeOutCubic = (k: number) => 1 - Math.pow(1 - k, 3); + +const formatClock = (seconds: number) => { + const s = Math.max(0, Math.floor(seconds)); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +}; + +type Timeline = { events: SomaticEvent[]; total: number }; + +const buildTimeline = ( + manifestations: ManifestationOutput[], + session: SessionOutput +): Timeline => { + const startMs = session.startTime ? Date.parse(session.startTime) : NaN; + const events: SomaticEvent[] = []; + + manifestations.forEach((m, i) => { + const resolution = resolveSomaticLocation(m.type, m.value); + if (!resolution) return; + + const beginMs = m.beginInputTimestamp ? Date.parse(m.beginInputTimestamp) : NaN; + const endMs = m.endInputTimestamp ? Date.parse(m.endInputTimestamp) : NaN; + if (!Number.isFinite(beginMs) || !Number.isFinite(startMs)) return; + + const t0 = Math.max(0, (beginMs - startMs) / 1000); + const rawDur = Number.isFinite(endMs) ? (endMs - beginMs) / 1000 : 0; + const dur = Math.min(MAX_DUR_S, Math.max(MIN_DUR_S, rawDur)); + + events.push({ + t0, + dur, + coord: somaticCoord(resolution.region, i + 1), + rgb: (m.valence && VALENCE_RGB[m.valence]) || DEFAULT_RGB, + label: (m.value || m.type || "").slice(0, 80), + region: resolution.region, + type: m.type || "", + source: resolution.source, + }); + }); + + events.sort((a, b) => a.t0 - b.t0); + const sessionEndMs = session.endTime ? Date.parse(session.endTime) : NaN; + const sessionSpan = Number.isFinite(sessionEndMs) ? (sessionEndMs - startMs) / 1000 : 0; + const lastEvent = events.reduce((acc, e) => Math.max(acc, e.t0 + e.dur), 0); + const total = Math.max(sessionSpan, lastEvent + FADE_S, 1); + return { events, total }; +}; + +type GlDataItem = { + value: [number, number, number]; + name: string; + symbolSize: number; + itemStyle: { color: string }; + region?: string; + expType?: string; +}; + +const eventDataAt = (events: SomaticEvent[], t: number) => { + const active: GlDataItem[] = []; + const residue: GlDataItem[] = []; + for (const e of events) { + if (e.t0 > t) break; // sorted by t0 + const age = t - e.t0; + const baseSize = 7 + Math.min(10, 6 * Math.log10(1 + e.dur)); + if (age <= e.dur + FADE_S) { + let scale: number; + let alpha: number; + if (age < RAMP_S) { + const k = easeOutCubic(age / RAMP_S); + scale = k; + alpha = 0.95 * k; + } else if (age <= e.dur) { + scale = 1; + alpha = 0.95; + } else { + const k = (age - e.dur) / FADE_S; + scale = 1 - 0.7 * k; + alpha = 0.95 * (1 - k); + } + active.push({ + value: e.coord, + name: e.label, + symbolSize: baseSize * scale, + itemStyle: { color: `rgba(${e.rgb},${alpha.toFixed(3)})` }, + region: e.region, + expType: e.type, + }); + } else { + residue.push({ + value: e.coord, + name: e.label, + symbolSize: 2.6, + itemStyle: { color: `rgba(${e.rgb},0.16)` }, + region: e.region, + expType: e.type, + }); + } + } + return { active, residue }; +}; + +const AXIS_OFF = { + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false }, +}; + +const buildBaseOption = (darkMode: boolean) => ({ + backgroundColor: "transparent", + animation: false, + tooltip: { + formatter: (params: { data?: GlDataItem }) => { + const d = params.data; + if (!d) return ""; + return `${d.name}
${d.region} · ${d.expType}`; + }, + }, + xAxis3D: { type: "value", min: -32, max: 32, ...AXIS_OFF }, + yAxis3D: { type: "value", min: -32, max: 32, ...AXIS_OFF }, + zAxis3D: { type: "value", min: -2, max: 104, ...AXIS_OFF }, + grid3D: { + show: false, + boxWidth: 64, + boxDepth: 64, + boxHeight: 106, + viewControl: { + distance: 210, + alpha: 5, + beta: 20, + center: [0, 0, 0], + autoRotate: false, + damping: 0.85, + }, + light: { + main: { intensity: 1.1, shadow: false }, + ambient: { intensity: 0.55 }, + }, + }, + series: [ + { + id: "body", + type: "scatter3D", + silent: true, + data: buildBodyPointCloud().map((p) => ({ value: p })), + symbolSize: 1.7, + itemStyle: { + color: darkMode ? "rgba(148,163,184,0.30)" : "rgba(100,116,139,0.26)", + }, + }, + { id: "residue", type: "scatter3D", silent: true, data: [], symbolSize: 2.6 }, + { + id: "events", + type: "scatter3D", + data: [], + emphasis: { itemStyle: { opacity: 1 } }, + }, + ], +}); + +type SomaticField3DProps = { + manifestations: ManifestationOutput[]; + session: SessionOutput; +}; + +const SomaticField3D = ({ manifestations, session }: SomaticField3DProps) => { + const { darkMode } = useSystem(); + const containerRef = useRef(null); + const chartRef = useRef(null); + const clockRef = useRef({ t: 0, last: 0, lastDisplay: 0 }); + const [isPlaying, setIsPlaying] = useState(false); + const [speed, setSpeed] = useState(8); + const [displayT, setDisplayT] = useState(0); + + const timeline = useMemo( + () => buildTimeline(manifestations || [], session), + [manifestations, session] + ); + + const renderFrame = (t: number) => { + const chart = chartRef.current; + if (!chart) return; + const { active, residue } = eventDataAt(timeline.events, t); + chart.setOption({ + series: [ + { id: "residue", data: residue }, + { id: "events", data: active }, + ], + }); + }; + + // Chart lifecycle: re-created when theme or the underlying timeline changes. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const chart = init(el, darkMode ? "dark" : "light", { renderer: "canvas" }); + chartRef.current = chart; + chart.setOption(buildBaseOption(darkMode)); + renderFrame(clockRef.current.t); + const resizeObserver = new ResizeObserver(() => chart.resize()); + resizeObserver.observe(el); + return () => { + resizeObserver.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [darkMode, timeline]); + + // Playback clock. + useEffect(() => { + if (!isPlaying) return; + clockRef.current.last = performance.now(); + let raf = requestAnimationFrame(function step(now: number) { + const clock = clockRef.current; + clock.t = Math.min(timeline.total, clock.t + ((now - clock.last) / 1000) * speed); + clock.last = now; + renderFrame(clock.t); + if (now - clock.lastDisplay > 200) { + clock.lastDisplay = now; + setDisplayT(clock.t); + } + if (clock.t >= timeline.total) { + setDisplayT(clock.t); + setIsPlaying(false); + return; + } + raf = requestAnimationFrame(step); + }); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPlaying, speed, timeline]); + + const seek = (t: number) => { + clockRef.current.t = t; + setDisplayT(t); + renderFrame(t); + }; + + const togglePlay = () => { + if (!isPlaying && clockRef.current.t >= timeline.total) seek(0); + setIsPlaying(!isPlaying); + }; + + const atEnd = !isPlaying && displayT >= timeline.total; + const placed = timeline.events.length; + const totalCount = manifestations?.length || 0; + + if (!placed) { + return ( + + None of this session's experiences resolve to a somatic location yet — add + mappings in somaticMap.ts to place them in the body. + + ); + } + + return ( +
+
+ + {placed} of {totalCount} experiences placed in the somatic field + +
+ + + positive + + + + negative + + + + neutral + +
+
+ +
+ +
+ + {isPlaying ? : atEnd ? : } + + { + setIsPlaying(false); + seek((parseInt(e.target.value, 10) / 1000) * timeline.total); + }} + className="flex-grow accent-ds-accent cursor-pointer" + /> + + {formatClock(displayT)} / {formatClock(timeline.total)} + +
+