Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions client/src/components/Echart/somaticField3D/SomaticFieldDemo.tsx
Original file line number Diff line number Diff line change
@@ -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:
*
* <SomaticFieldDemo height="480px" />
*/

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<HTMLDivElement>(null);
const chartRef = useRef<ECharts | null>(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 <div ref={containerRef} className={`w-full ${className}`} style={{ height }} />;
};

export default SomaticFieldDemo;
109 changes: 109 additions & 0 deletions client/src/components/Echart/somaticField3D/bodyModel.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading