import React, { useState, useEffect, useRef } from "react";
// ─────────────────────────────────────────────────────────────
// CANÂ Regulation Pulse™
// Take your Nervous System Pulse in 30 Seconds.
// ─────────────────────────────────────────────────────────────
// ── CONEXIÓN A LA BASE DE DATOS (Supabase) ──
// Pega aquí los dos valores de tu proyecto Supabase (ver la guía).
// Si los dejas vacíos, la app funciona igual pero el mapa muestra datos de ejemplo.
const SUPABASE_URL = "https://ylxasrxzfiiuhlvjedbn.supabase.co"; // ej: https://xxxxxxxx.supabase.co
const SUPABASE_KEY = "sb_publishable_r5vzqNjUm_A48dUmpE17lw_lHqZwIE1"; // la "anon public key"
const backendReady = () => SUPABASE_URL.length > 0 && SUPABASE_KEY.length > 0;
// Detecta la ciudad de la persona automáticamente (sin pedir permiso ni datos)
// Intenta varios servicios por si alguno falla o limita peticiones.
async function detectCity() {
// Servicio 1: ipapi.co
try {
const res = await fetch("https://ipapi.co/json/");
if (res.ok) {
const d = await res.json();
if (d && d.city) return { city: d.city, country: d.country_name || null, countryCode: d.country_code || null };
}
} catch (e) { /* sigue al siguiente */ }
// Servicio 2: ipwho.is (respaldo)
try {
const res = await fetch("https://ipwho.is/");
if (res.ok) {
const d = await res.json();
if (d && d.city) return { city: d.city, country: d.country || null, countryCode: d.country_code || null };
}
} catch (e) { /* sigue al siguiente */ }
// Servicio 3: geojs.io (respaldo)
try {
const res = await fetch("https://get.geojs.io/v1/ip/geo.json");
if (res.ok) {
const d = await res.json();
if (d && d.city) return { city: d.city, country: d.country || null, countryCode: d.country_code || null };
}
} catch (e) { /* nada */ }
return null;
}
// Guarda un pulso de forma anónima (cinco valores + estado + ciudad)
async function savePulse(stateKey, values, place) {
if (!backendReady()) return;
try {
await fetch(`${SUPABASE_URL}/rest/v1/pulses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: SUPABASE_KEY,
Prefer: "return=minimal",
},
body: JSON.stringify({
state: stateKey,
calm: values.calm, clarity: values.clarity, energy: values.energy,
presence: values.presence, connection: values.connection,
city: place?.city || null, country: place?.country || null,
}),
});
} catch (e) { /* silencioso */ }
}
// Lee el reparto de estados, opcionalmente filtrado por ciudad
async function fetchCollective(city) {
if (!backendReady()) return null;
try {
const url = city
? `${SUPABASE_URL}/rest/v1/pulse_distribution?city=eq.${encodeURIComponent(city)}`
: `${SUPABASE_URL}/rest/v1/pulse_distribution`;
const res = await fetch(url, {
headers: { apikey: SUPABASE_KEY },
});
if (!res.ok) return null;
return await res.json();
} catch (e) { return null; }
}
const PALETTE = {
ink: "#0E1512", // deep forest-black background
ink2: "#141E19", // panel
bone: "#EDE7D8", // primary text (warm bone)
bone2: "#9AA79B", // muted text
sage: "#6FA287", // green accent (calm/regulation)
gold: "#E4B23C", // yellow accent (energy/highlight)
clay: "#C97B4A", // terracotta (activation)
line: "rgba(237,231,216,0.12)",
};
const METRICS = [
{ key: "calm", label: "CALM", prompt: { es: "Hoy mi cuerpo se siente…", en: "Today my body feels…" }, low: { es: "Muy activado", en: "Very activated" }, high: { es: "Muy calmado", en: "Very calm" }, es: "Calma", enName: "Calm", def: { es: "El estado de tu sistema nervioso: en reposo o en alerta.", en: "The state of your nervous system: at rest or on alert." } },
{ key: "clarity", label: "CLARITY", prompt: { es: "Mi mente se siente…", en: "My mind feels…" }, low: { es: "Muy saturada", en: "Very cluttered" }, high: { es: "Muy clara", en: "Very clear" }, es: "Claridad", enName: "Clarity", def: { es: "Qué tan enfocada y despejada está tu mente ahora.", en: "How focused and clear your mind is right now." } },
{ key: "energy", label: "ENERGY", prompt: { es: "Mi energía hoy es…", en: "My energy today is…" }, low: { es: "Muy baja", en: "Very low" }, high: { es: "Muy alta", en: "Very high" }, es: "Energía", enName: "Energy", def: { es: "Tu carga vital disponible: cansancio o vitalidad.", en: "Your available vital charge: fatigue or vitality." } },
{ key: "presence", label: "PRESENCE", prompt: { es: "Me siento…", en: "I feel…" }, low: { es: "Desconectado", en: "Disconnected" }, high: { es: "Muy presente", en: "Very present" }, es: "Presencia", enName: "Presence", def: { es: "Qué tanto estás en el aquí y ahora, no en tu cabeza.", en: "How much you're in the here and now, not in your head." } },
{ key: "connection", label: "CONNECTION", prompt: { es: "Me siento…", en: "I feel…" }, low: { es: "Muy aislado", en: "Very isolated" }, high: { es: "Muy conectado", en: "Very connected" }, es: "Conexión", enName: "Connection", def: { es: "Tu sensación de vínculo con otros y con tu entorno.", en: "Your sense of bond with others and your surroundings." } },
];
// The CANÂ vocabulary — a language, not a score.
const PULSE_STATES = [
{ key: "grounded", glyph: "🌿", name: "Grounded", esName: "Enraizado", color: "#6FA287", desc: { es: "Tu sistema descansa en calma y seguridad.", en: "Your system rests in calm and safety." } },
{ key: "flowing", glyph: "🌊", name: "Flowing", esName: "En Flujo", color: "#4E9AA6", desc: { es: "Energía y calma en equilibrio. Tu mejor estado.", en: "Energy and calm in balance. Your best state." } },
{ key: "activated", glyph: "⚡", name: "Activated", esName: "Activado", color: "#E4B23C", desc: { es: "Tu sistema está encendido y en alerta.", en: "Your system is switched on and alert." } },
{ key: "overloaded", glyph: "🌫", name: "Overloaded", esName: "Sobrecargado", color: "#C97B4A", desc: { es: "Demasiada carga acumulada, sin descarga.", en: "Too much load built up, with no release." } },
{ key: "recovering", glyph: "🕊", name: "Recovering", esName: "Recuperando", color: "#B69ADf", desc: { es: "Tu sistema está descansando y reparándose.", en: "Your system is resting and repairing." } },
];
// Identity circles (alternate entry mode)
const DAY_STATES = [
{ key: "flowing", dot: "🟢", label: { es: "Estoy fluyendo.", en: "I'm flowing." }, weight: 1.0 },
{ key: "coping", dot: "🟡", label: { es: "Estoy funcionando.", en: "I'm functioning." }, weight: 0.72 },
{ key: "surviving", dot: "🟠", label: { es: "Estoy sobreviviendo.", en: "I'm surviving." }, weight: 0.48 },
{ key: "depleted", dot: "🔴", label: { es: "Estoy agotado.", en: "I'm depleted." }, weight: 0.28 },
{ key: "flooded", dot: "⚫", label: { es: "Estoy completamente desbordado.", en: "I'm completely overwhelmed." }, weight: 0.12 },
];
// Aggregate country data (illustrative)
const COUNTRY_PULSE = [
{ key: "recovering", name: "Recovering", pct: 27, color: "#B69ADF" },
{ key: "flowing", name: "Flowing", pct: 22, color: "#4E9AA6" },
{ key: "activated", name: "Activated", pct: 19, color: "#E4B23C" },
{ key: "grounded", name: "Grounded", pct: 18, color: "#6FA287" },
{ key: "overloaded", name: "Overloaded", pct: 14, color: "#C97B4A" },
];
// ── UI strings, bilingual ──
const T = {
tagline: { es: "Toma el pulso de tu sistema nervioso en 30 segundos.", en: "Take your Nervous System Pulse in 30 Seconds." },
introQ: { es: "¿Cómo está tu sistema nervioso hoy?", en: "How is your nervous system today?" },
introSub: { es: "No es una encuesta. Es tu pulso. Menos de 30 segundos.", en: "Not a survey. It's your pulse. Under 30 seconds." },
takePulse: { es: "Tomar mi pulso", en: "Take my pulse" },
measureHint: { es: "Cinco indicadores. Mueve cada uno según tu día.", en: "Five indicators. Move each one to match your day." },
remeasureHint: { es: "Vuelve a medir. Mueve cada indicador según cómo te sientes ahora.", en: "Measure again. Move each indicator to how you feel now." },
seePulse: { es: "Ver mi pulso", en: "See my pulse" },
todayPulse: { es: "Tu Pulso CANÂ de hoy", en: "Your CANÂ Pulse today" },
recommend: { es: "Hoy te recomendamos", en: "Today we recommend" },
blissSub: { es: "2 minutos · sonido + respiración", en: "2 minutes · sound + breath" },
listen: { es: "▶ Escuchar", en: "▶ Listen" },
sessionOn: { es: "BLISS · en curso", en: "BLISS · in session" },
inhale: { es: "Inhala… exhala…", en: "Inhale… exhale…" },
secondsLeft: { es: "s restantes", en: "s left" },
backToMeasure: { es: "Volver a medir →", en: "Measure again →" },
skipMeasure: { es: "Saltar y medir →", en: "Skip and measure →" },
soundPlaying: { es: "Sonando", en: "Playing" },
soundResume: { es: "Reanudar sonido", en: "Resume sound" },
soundLabelBliss: { es: "música de relajación 432 Hz · campanas · cuenco tibetano · lluvia suave", en: "432 Hz relaxation music · chimes · singing bowl · soft rain" },
soundLabelVitality: { es: "música activadora 432 Hz · ritmo · brillo · beta 16 Hz", en: "432 Hz energizing music · rhythm · sparkle · beta 16 Hz" },
pulseChanged: { es: "Tu Pulso cambió.", en: "Your Pulse changed." },
beforeAfter: { es: "Antes → Después", en: "Before → After" },
seeCollective: { es: "Ver el pulso colectivo →", en: "See the collective pulse →" },
startOver: { es: "Empezar de nuevo", en: "Start over" },
countryQ: { es: "¿Cómo está respirando tu comunidad hoy?", en: "How is your community breathing today?" },
countrySub: { es: "Sin patologías. Sin etiquetas clínicas. Solo el lenguaje CANÂ.", en: "No pathology. No clinical labels. Only the CANÂ language." },
takeAnother: { es: "↺ Tomar otro pulso", en: "↺ Take another pulse" },
recommendCalm: { es: "Tu sistema está activado. Hoy te recomendamos calmar:", en: "Your system is activated. Today we recommend calming:" },
recommendEnergy: { es: "Tu energía está baja. Hoy te recomendamos activar:", en: "Your energy is low. Today we recommend energizing:" },
blissSubShort: { es: "5 min · calmar", en: "5 min · calm" },
vitalitySubShort: { es: "5 min · activar", en: "5 min · energize" },
// Breathing guidance shown during the session
breathGuideCalm: {
es: "Mira fijamente el centro del círculo. Coloca una mano en el pecho y otra en el abdomen. Y solo respira.",
en: "Gaze softly at the center of the circle. Place one hand on your chest and one on your belly. And just breathe.",
},
breathGuideEnergy: {
es: "Mira el centro del círculo. Respira más rápido y profundo. Siente cómo entra la energía.",
en: "Look at the center of the circle. Breathe faster and deeper. Feel the energy coming in.",
},
breathGuide: {
es: "Mira fijamente el centro del círculo. Coloca una mano en el pecho y otra en el abdomen. Y solo respira.",
en: "Gaze softly at the center of the circle. Place one hand on your chest and one on your belly. And just breathe.",
},
};
function scoreToState(avg) {
// avg 0..100 → a CANÂ word
if (avg >= 78) return PULSE_STATES[0]; // grounded
if (avg >= 62) return PULSE_STATES[1]; // flowing
if (avg >= 46) return PULSE_STATES[2]; // activated
if (avg >= 30) return PULSE_STATES[4]; // recovering
return PULSE_STATES[3]; // overloaded
}
// ─── Circular gauge component ───
function Ring({ pct, color, size = 132, stroke = 9, label, value, delay = 0 }) {
const [shown, setShown] = useState(0);
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
useEffect(() => {
const t = setTimeout(() => setShown(pct), delay);
return () => clearTimeout(t);
}, [pct, delay]);
return (
{value != null ? value : `${Math.round(shown)}`}
{label}
);
}
// ─── Slider ───
function PulseSlider({ metric, value, onChange, lang }) {
return (
{metric.label}
{value}
{metric.prompt[lang]}
{metric.def[lang]}
onChange(Number(e.target.value))}
style={{ width: "100%", accentColor: PALETTE.sage, cursor: "pointer" }}
/>
{metric.low[lang]}
{metric.high[lang]}
);
}
// ─── Invitación a la web (se usa al final del recorrido) ───
function CanaInvite({ lang, PALETTE }) {
return (
{lang === "es" ? "Esto es solo el comienzo." : "This is just the beginning."}
{lang === "es"
? "Descubre cómo Canâ diseña experiencias para regular tu sistema nervioso a través de los sentidos."
: "Discover how Canâ designs experiences to regulate your nervous system through the senses."}
{lang === "es" ? "Conoce Canâ →" : "Discover Canâ →"}
);
}
// ─────────────────────────────────────────────────────────────
// BLISS Frequency Engine — 432 Hz base, generado en tiempo real
// · Tono base 432 Hz + drone suave (quinta justa)
// · Beat binaural: 8 Hz (banda alpha, calma alerta)
// · Latido de coherencia: pulso cada ~10 s (0.1 Hz) que guía la respiración
// Todo con Web Audio API — sin archivos, frecuencia exacta y limpia.
// ─────────────────────────────────────────────────────────────
function useBlissFrequency() {
const ctxRef = useRef(null);
const nodesRef = useRef([]);
const [playing, setPlaying] = useState(false);
const [paused, setPaused] = useState(false);
// Pausar de verdad (mantiene el audio vivo, solo lo suspende)
const pause = () => {
const ctx = ctxRef.current;
if (ctx && ctx.state === "running") { ctx.suspend(); setPaused(true); }
};
// Reanudar el mismo audio suspendido
const resume = () => {
const ctx = ctxRef.current;
if (ctx && ctx.state === "suspended") { ctx.resume(); setPaused(false); }
};
const stop = () => {
const ctx = ctxRef.current;
if (ctx) {
const now = ctx.currentTime;
nodesRef.current.forEach((n) => {
if (n.gain) {
try { n.gain.gain.cancelScheduledValues(now); n.gain.gain.setTargetAtTime(0, now, 0.4); } catch (e) {}
}
});
setTimeout(() => {
nodesRef.current.forEach((n) => {
try { n.osc && n.osc.stop(); } catch (e) {}
if (n.timer) clearTimeout(n.timer);
});
nodesRef.current = [];
try { ctx.close(); } catch (e) {}
ctxRef.current = null;
}, 700);
} else {
nodesRef.current.forEach((n) => { if (n.timer) clearTimeout(n.timer); });
}
setPlaying(false);
};
const start = (mode = "bliss") => {
if (ctxRef.current) return;
const Ctx = window.AudioContext || window.webkitAudioContext;
const ctx = new Ctx();
ctxRef.current = ctx;
const nodes = [];
const master = ctx.createGain();
master.gain.value = 0;
master.gain.setTargetAtTime(mode === "vitality" ? 0.4 : 0.42, ctx.currentTime, mode === "vitality" ? 1.4 : 2.5);
master.connect(ctx.destination);
// Reverberación (más corta y brillante para vitality, amplia para bliss)
let reverb = null;
try {
const rev = ctx.createConvolver();
const len = ctx.sampleRate * (mode === "vitality" ? 1.4 : 3);
const impulse = ctx.createBuffer(2, len, ctx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const d = impulse.getChannelData(ch);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, mode === "vitality" ? 3.5 : 2.8);
}
rev.buffer = impulse;
const revGain = ctx.createGain(); revGain.gain.value = mode === "vitality" ? 0.22 : 0.35;
rev.connect(revGain); revGain.connect(master);
reverb = rev;
} catch (e) {}
// Voz: oscilador con panorámica + ganancia (con envío opcional a reverb)
const voice = (freq, type, level, pan, toReverb = false) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const panner = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
osc.type = type; osc.frequency.value = freq;
gain.gain.value = level;
osc.connect(gain);
const dest = panner || master;
if (panner) { panner.pan.value = pan; gain.connect(panner); panner.connect(master); }
else { gain.connect(master); }
if (toReverb && reverb) gain.connect(reverb);
osc.start();
nodes.push({ osc, gain });
return { osc, gain };
};
// ══════════ BLISS — calmar (theta, lento, cálido) ══════════
if (mode === "bliss") {
// 1) Beat binaural THETA (5 Hz): 432 izq / 437 der — casi inaudible, subliminal
voice(432, "sine", 0.022, -1);
voice(437, "sine", 0.022, 1);
// 2) Drone base cálido (216 Hz), apenas un susurro
voice(216, "sine", 0.03, 0);
// 3) MÚSICA — pads de acordes lentos y suaves afinados en 432 Hz.
// Progresión ambient que respira; notas derivadas de A=432.
const A = 432;
const HZ = {
A2: A / 2, // 216
C3: A / 2 * 1.1892, // ~256.9 (Do)
D3: A / 2 * 1.3348, // ~288.3 (Re)
E3: A * 0.75, // 324 (Mi)
F3: A * 0.7937, // ~342.9 (Fa)
G3: A * 0.8909, // ~384.9 (Sol)
A3: A, // 432 (La)
C4: A * 1.1892, // ~513.8
D4: A * 1.3348, // ~576.6
E4: A * 1.5, // 648
G4: A * 1.7818, // ~769.7
};
// Progresión lenta de 4 acordes, tono cálido y abierto
const chords = [
[HZ.A2, HZ.C3, HZ.E3, HZ.A3], // Lam
[HZ.F3, HZ.A3, HZ.C4], // Fa
[HZ.C3, HZ.E3, HZ.G3, HZ.C4], // Do
[HZ.G3, HZ.D4, HZ.G4], // Sol
];
const chordDur = 16; // aún más lento y flotante
const allNotes = [...new Set(chords.flat())];
const padGains = {};
allNotes.forEach((f) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine"; // sine puro = más suave y dulce
osc.frequency.value = f;
gain.gain.value = 0;
osc.connect(gain);
if (reverb) gain.connect(reverb);
gain.connect(master);
osc.start();
padGains[f] = gain;
nodes.push({ osc, gain });
});
let chordIdx = 0;
const stepChord = () => {
if (!ctxRef.current) return;
const now = ctx.currentTime;
const active = chords[chordIdx % chords.length];
allNotes.forEach((f) => {
const g = padGains[f];
const target = active.includes(f) ? 0.09 : 0; // música al frente
g.gain.cancelScheduledValues(now);
g.gain.setTargetAtTime(target, now, chordDur * 0.45); // fundido muy largo
});
chordIdx++;
const t = setTimeout(stepChord, chordDur * 1000);
nodes.push({ timer: t });
};
stepChord();
// 4) CAMPANAS — más frecuentes y con más variedad de notas.
// Timbre de campana real: fundamental + armónicos con decaimiento largo.
const chimeNotes = [HZ.A3, HZ.C4, HZ.D4, HZ.E4, HZ.G4, HZ.G3];
const playChime = (f, level = 0.06) => {
if (!ctxRef.current) return;
const now = ctx.currentTime;
// fundamental + dos armónicos suaves para timbre de campana
[[1, 1], [2, 0.4], [3, 0.15]].forEach(([mult, amp]) => {
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = "sine"; o.frequency.value = f * 2 * mult;
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(level * amp, now + 0.04);
g.gain.exponentialRampToValueAtTime(0.0001, now + 5.5); // cola larga y brillante
o.connect(g);
if (reverb) g.connect(reverb);
g.connect(master);
o.start(now); o.stop(now + 5.7);
});
};
const chime = () => {
if (!ctxRef.current) return;
const f = chimeNotes[Math.floor(Math.random() * chimeNotes.length)];
playChime(f);
const next = 4000 + Math.random() * 5000; // cada 4–9 s (más frecuentes)
const t = setTimeout(chime, next);
nodes.push({ timer: t });
};
nodes.push({ timer: setTimeout(chime, 2500) });
// 5) CUENCO TIBETANO ocasional — nota grave sostenida que aparece de vez en cuando
const bowl = () => {
if (!ctxRef.current) return;
const now = ctx.currentTime;
const o = ctx.createOscillator();
const o2 = ctx.createOscillator();
const g = ctx.createGain();
o.type = "sine"; o.frequency.value = HZ.A2; // 216
o2.type = "sine"; o2.frequency.value = HZ.A2 * 1.003; // leve batido, "vivo"
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(0.07, now + 0.8); // ataque suave
g.gain.exponentialRampToValueAtTime(0.0001, now + 9); // decaimiento muy largo
o.connect(g); o2.connect(g);
if (reverb) g.connect(reverb);
g.connect(master);
o.start(now); o2.start(now); o.stop(now + 9.2); o2.stop(now + 9.2);
const next = 22000 + Math.random() * 14000; // cada 22–36 s
const t = setTimeout(bowl, next);
nodes.push({ timer: t });
};
nodes.push({ timer: setTimeout(bowl, 8000) });
// 6) NATURALEZA — lluvia muy suave de fondo
const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 4, ctx.sampleRate);
const data = noiseBuf.getChannelData(0);
let b0 = 0, b1 = 0, b2 = 0;
for (let i = 0; i < data.length; i++) {
const white = Math.random() * 2 - 1;
b0 = 0.99765 * b0 + white * 0.0990460;
b1 = 0.96300 * b1 + white * 0.2965164;
b2 = 0.57000 * b2 + white * 1.0526913;
data[i] = (b0 + b1 + b2 + white * 0.1848) * 0.16;
}
const noise = ctx.createBufferSource();
noise.buffer = noiseBuf; noise.loop = true;
const rain = ctx.createBiquadFilter();
rain.type = "lowpass"; rain.frequency.value = 1100; rain.Q.value = 0.4;
const rainGain = ctx.createGain(); rainGain.gain.value = 0.16; // muy sutil
noise.connect(rain); rain.connect(rainGain); rainGain.connect(master);
noise.start();
nodes.push({ osc: noise });
// 7) Latido de coherencia — LFO 0.1 Hz que respira el volumen del master
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.type = "sine"; lfo.frequency.value = 0.1;
lfoGain.gain.value = 0.06;
lfo.connect(lfoGain); lfoGain.connect(master.gain);
lfo.start();
nodes.push({ osc: lfo });
} // fin BLISS
// ══════════ VITALITY — activar (beta alto, rítmico, brillante) ══════════
if (mode === "vitality") {
// 1) Beat binaural BETA (~16 Hz): 432 izq / 448 der — alerta y enfoque
voice(432, "sine", 0.05, -1);
voice(448, "sine", 0.05, 1);
// 2) Música brillante — acordes mayores luminosos afinados en 432 Hz
const A = 432;
const NB = {
A2: A / 2, Csh3: A / 2 * 1.2599, E3: A * 0.75, A3: A,
Csh4: A * 1.2599, E4: A * 1.5, Gsh4: A * 1.8877, B4: A * 2.2449,
D4: A * 1.3348, Fsh4: A * 1.6818,
};
// Progresión mayor alegre: La → Re → Mi → La (más rápida que bliss)
const chords = [
[NB.A2, NB.Csh3, NB.E3, NB.A3], // La mayor
[NB.A2, NB.D4, NB.Fsh4], // Re mayor
[NB.E3, NB.Gsh4, NB.B4], // Mi mayor
[NB.A3, NB.Csh4, NB.E4], // La mayor agudo
];
const chordDur = 6; // más ágil
const allNotes = [...new Set(chords.flat())];
const padGains = {};
allNotes.forEach((f) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sawtooth"; // más brillante y con cuerpo
osc.frequency.value = f;
gain.gain.value = 0;
// filtro para suavizar el sawtooth (que no sea áspero)
const filt = ctx.createBiquadFilter();
filt.type = "lowpass"; filt.frequency.value = 2400; filt.Q.value = 0.6;
osc.connect(filt); filt.connect(gain);
if (reverb) gain.connect(reverb);
gain.connect(master);
osc.start();
padGains[f] = gain;
nodes.push({ osc, gain });
});
let ci = 0;
const stepChord = () => {
if (!ctxRef.current) return;
const now = ctx.currentTime;
const active = chords[ci % chords.length];
allNotes.forEach((f) => {
const g = padGains[f];
g.gain.cancelScheduledValues(now);
g.gain.setTargetAtTime(active.includes(f) ? 0.06 : 0, now, chordDur * 0.3);
});
ci++;
const tt = setTimeout(stepChord, chordDur * 1000);
nodes.push({ timer: tt });
};
stepChord();
// 3) PULSO RÍTMICO — un latido tipo tambor suave que da energía y ritmo
const beat = () => {
if (!ctxRef.current) return;
const now = ctx.currentTime;
// "kick" suave: seno grave que cae rápido
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = "sine"; o.frequency.setValueAtTime(160, now);
o.frequency.exponentialRampToValueAtTime(60, now + 0.12);
g.gain.setValueAtTime(0.14, now);
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.28);
o.connect(g); g.connect(master);
o.start(now); o.stop(now + 0.3);
const tt = setTimeout(beat, 600); // ~100 BPM, ritmo activador estable
nodes.push({ timer: tt });
};
nodes.push({ timer: setTimeout(beat, 400) });
// 4) CAMPANITAS brillantes agudas — chispa de energía
const sparkleNotes = [NB.E4, NB.Gsh4, NB.B4, NB.Csh4];
const sparkle = () => {
if (!ctxRef.current) return;
const now = ctx.currentTime;
const f = sparkleNotes[Math.floor(Math.random() * sparkleNotes.length)];
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = "triangle"; o.frequency.value = f * 2;
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(0.05, now + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, now + 1.6);
o.connect(g);
if (reverb) g.connect(reverb);
g.connect(master);
o.start(now); o.stop(now + 1.7);
const tt = setTimeout(sparkle, 1800 + Math.random() * 2600);
nodes.push({ timer: tt });
};
nodes.push({ timer: setTimeout(sparkle, 1500) });
// 5) Brillo de fondo — aire agudo suave (ruido filtrado alto)
const nb = ctx.createBuffer(1, ctx.sampleRate * 3, ctx.sampleRate);
const nd = nb.getChannelData(0);
for (let i = 0; i < nd.length; i++) nd[i] = (Math.random() * 2 - 1) * 0.5;
const air = ctx.createBufferSource();
air.buffer = nb; air.loop = true;
const hp = ctx.createBiquadFilter();
hp.type = "highpass"; hp.frequency.value = 6000;
const airGain = ctx.createGain(); airGain.gain.value = 0.05;
air.connect(hp); hp.connect(airGain); airGain.connect(master);
air.start();
nodes.push({ osc: air });
} // fin VITALITY
nodes.push({ gain: master });
nodesRef.current = nodes;
setPlaying(true);
};
// Baja el volumen suavemente (~4 s) y luego detiene — para el final de la sesión
const fadeOut = () => {
const ctx = ctxRef.current;
if (!ctx) return;
const master = nodesRef.current.find((n) => n.gain && !n.osc);
if (master && master.gain) {
const now = ctx.currentTime;
master.gain.gain.cancelScheduledValues(now);
master.gain.gain.setTargetAtTime(0, now, 1.3); // descenso suave
}
setTimeout(() => stop(), 4200);
};
useEffect(() => () => stop(), []); // limpiar al desmontar
return { playing, paused, start, stop, fadeOut, pause, resume };
}
export default function CanaPulse() {
const [screen, setScreen] = useState("intro"); // intro, measure, result, session, remeasure, result2, map
const [values, setValues] = useState(Object.fromEntries(METRICS.map((m) => [m.key, 3])));
const [firstResult, setFirstResult] = useState(null);
const [secondValues, setSecondValues] = useState(null);
const [sessionSeconds, setSessionSeconds] = useState(300);
const [lang, setLang] = useState("es"); // "es" | "en"
const [collective, setCollective] = useState(null); // datos reales del mapa
const [sessionMode, setSessionMode] = useState("bliss"); // "bliss" (calmar) | "vitality" (activar)
const [place, setPlace] = useState(null); // { city, country }
const bliss = useBlissFrequency();
// Detecta la ciudad una vez al abrir la app (silencioso, sin pedir permiso)
useEffect(() => { detectCity().then((p) => { if (p) setPlace(p); }); }, []);
const t = (key) => T[key][lang]; // traductor
const pct = (v) => Math.round(((v - 1) / 4) * 100);
const avg = (obj) => Math.round(METRICS.reduce((s, m) => s + pct(obj[m.key]), 0) / METRICS.length);
// Session timer + BLISS frequency (5 minutos reales)
useEffect(() => {
if (screen !== "session") { bliss.stop(); return; }
setSessionSeconds(300);
const id = setInterval(() => {
setSessionSeconds((s) => {
if (s <= 1) {
clearInterval(id);
bliss.fadeOut(); // baja el volumen suavemente al terminar
// Al terminar la música, pasa automáticamente a medir cómo se siente ahora
setTimeout(() => setScreen("remeasure"), 5000);
return 0;
}
return s - 1;
});
}, 1000); // tiempo real: 1 segundo
return () => { clearInterval(id); bliss.stop(); };
}, [screen]); // audio se inicia en el botón "Escuchar" (gesto del usuario)
// Carga los datos reales del mapa colectivo (de la ciudad detectada) al abrirlo
useEffect(() => {
if (screen !== "map") return;
fetchCollective(place?.city).then((data) => {
if (data && data.length) setCollective(data);
});
}, [screen]);
const wrap = {
minHeight: "100vh",
background: `radial-gradient(1200px 600px at 50% -10%, #16221C 0%, ${PALETTE.ink} 55%)`,
color: PALETTE.bone,
fontFamily: "'Inter', system-ui, sans-serif",
display: "flex", flexDirection: "column", alignItems: "center",
padding: "0 20px",
};
const card = {
width: "100%", maxWidth: 480, margin: "0 auto",
};
const btn = (primary = true) => ({
width: "100%", padding: "16px", borderRadius: 999, border: "none",
background: primary ? PALETTE.sage : "transparent",
color: primary ? PALETTE.ink : PALETTE.bone2,
fontSize: 15, fontWeight: 600, letterSpacing: "0.02em", cursor: "pointer",
boxShadow: primary ? "0 8px 30px rgba(111,162,135,0.25)" : "none",
outline: primary ? "none" : `1px solid ${PALETTE.line}`,
transition: "transform .15s",
});
const langToggle = (
{["es", "en"].map((L) => (
setLang(L)}
style={{
padding: "4px 12px", borderRadius: 999, cursor: "pointer", border: "none",
background: lang === L ? PALETTE.sage : "transparent",
color: lang === L ? PALETTE.ink : PALETTE.bone2,
fontSize: 12, fontWeight: 700, letterSpacing: "0.08em",
}}
>
{L.toUpperCase()}
))}
);
const wordmark = (
CANÂ
Regulation Pulse
{langToggle}
);
// ─── INTRO ───
if (screen === "intro") {
return (
{wordmark}
{t("introQ")}
{t("introSub")}
setScreen("measure")}>{t("takePulse")}
);
}
// ─── MEASURE ───
if (screen === "measure" || screen === "remeasure") {
const isRe = screen === "remeasure";
const state = isRe ? secondValues || values : values;
const setState = isRe ? setSecondValues : setValues;
const cur = state || Object.fromEntries(METRICS.map((m) => [m.key, 3]));
return (
{wordmark}
{isRe ? t("remeasureHint") : t("measureHint")}
{METRICS.map((m) => (
setState({ ...cur, [m.key]: v })} />
))}
{
if (isRe) { setScreen("result2"); }
else {
setFirstResult({ ...cur });
// Guarda el pulso anónimo (con ciudad) en la base de datos
const st = scoreToState(avg(cur));
savePulse(st.key, {
calm: pct(cur.calm), clarity: pct(cur.clarity), energy: pct(cur.energy),
presence: pct(cur.presence), connection: pct(cur.connection),
}, place);
setScreen("result");
}
}}>
{t("seePulse")}
);
}
// ─── RESULT (first) ───
if (screen === "result") {
const overall = avg(firstResult || values);
const state = scoreToState(overall);
// Recomendación: si la energía está baja → VITALITY (activar); si no → BLISS (calmar)
const energyPct = pct((firstResult || values).energy);
const recMode = energyPct < 50 ? "vitality" : "bliss";
return (
{wordmark}
{t("todayPulse")}
{state.glyph} {lang === "es" ? state.esName : state.name}
{state.desc.es}
{state.desc.en}
{METRICS.map((m, i) => {
const p = pct((firstResult || values)[m.key]);
const col = p >= 60 ? PALETTE.sage : p >= 40 ? PALETTE.gold : PALETTE.clay;
return ;
})}
{recMode === "vitality" ? t("recommendEnergy") : t("recommendCalm")}
{recMode === "vitality" ? "VITALITY" : "BLISS"}
{recMode === "vitality" ? t("vitalitySubShort") : t("blissSubShort")}
{ setSessionMode(recMode); bliss.start(recMode); setScreen("session"); }}>
{t("listen")}
);
}
// ─── SESSION ───
if (screen === "session") {
const total = 300;
const prog = ((total - sessionSeconds) / total) * 100;
const isVit = sessionMode === "vitality";
const accent = isVit ? PALETTE.clay : PALETTE.gold;
return (
{isVit ? "VITALITY" : "BLISS"} · {lang === "es" ? "en curso" : "in session"}
{/* Frase guía */}
{isVit ? t("breathGuideEnergy") : t("breathGuideCalm")}
{/* Orbe holográfico — respira más rápido en modo activar */}
{isVit ? (lang === "es" ? "Respira… con energía" : "Breathe… with energy") : t("inhale")}
{Math.floor(sessionSeconds / 60)}:{String(Math.floor(sessionSeconds % 60)).padStart(2, "0")}
{/* Control del sonido — pausa/reanuda, SOLO mientras dura la sesión */}
{sessionSeconds > 0 && (
<>
(bliss.paused ? bliss.resume() : bliss.pause())}
style={{
display: "flex", alignItems: "center", gap: 10,
padding: "12px 22px", borderRadius: 999, cursor: "pointer",
background: !bliss.paused ? (isVit ? "rgba(201,123,74,0.14)" : "rgba(228,178,60,0.12)") : "transparent",
border: `1px solid ${!bliss.paused ? accent : PALETTE.line}`,
color: !bliss.paused ? accent : PALETTE.bone2, fontSize: 14, fontWeight: 600,
}}
>
{!bliss.paused ? "❚❚" : "▶"}
{bliss.paused ? t("soundResume") : t("soundPlaying")}
{isVit ? t("soundLabelVitality") : t("soundLabelBliss")}
>
)}
{sessionSeconds === 0 ? (
setScreen("remeasure")}>
{lang === "es" ? "Sesión completa · veamos cómo te sientes →" : "Session complete · let's see how you feel →"}
) : (
setScreen("remeasure")}>
{t("skipMeasure")}
)}
);
}
// ─── RESULT 2 (comparison) ───
if (screen === "result2") {
const before = firstResult || values;
const after = secondValues || values;
return (
{wordmark}
{t("pulseChanged")}
{t("beforeAfter")}
{METRICS.map((m) => {
const b = pct(before[m.key]); const a = pct(after[m.key]);
const up = a >= b;
return (
{lang === "es" ? m.es : m.enName}
{b}
→
{a}
);
})}
{/* Invitación a visitar la web */}
setScreen("map")}>{t("seeCollective")}
{ setValues(Object.fromEntries(METRICS.map((m) => [m.key, 3]))); setSecondValues(null); setScreen("intro"); }}>
{t("startOver")}
);
}
// ─── MAP (collective) ───
if (screen === "map") {
// Usa datos reales si la base de datos está conectada y tiene registros; si no, ejemplo
const rows = (collective || COUNTRY_PULSE).map((r) => {
const rawKey = (r.key || r.state || "").toString().trim().toLowerCase();
const meta = PULSE_STATES.find((p) => p.key === rawKey);
return {
key: rawKey,
name: meta ? (lang === "es" ? meta.esName : meta.name) : (r.name || r.state || rawKey),
glyph: meta ? meta.glyph : "",
color: meta ? meta.color : PALETTE.sage,
desc: meta ? (lang === "es" ? meta.desc.es : meta.desc.en) : "",
pct: Math.round(r.pct),
};
}).sort((a, b) => b.pct - a.pct);
const isReal = !!collective;
return (
{wordmark}
{place?.city
? (lang === "es" ? `¿Cómo está respirando ${place.city} hoy?` : `How is ${place.city} breathing today?`)
: t("countryQ")}
{t("countrySub")}
{rows.map((s) => (
{s.name}
{s.pct}%
{s.desc && (
{s.desc}
)}
))}
{!isReal && (
{lang === "es" ? "Datos de ejemplo · conecta la base de datos para el mapa real." : "Sample data · connect the database for the live map."}
)}
{/* Invitación a visitar la web */}
{ setValues(Object.fromEntries(METRICS.map((m) => [m.key, 3]))); setSecondValues(null); setCollective(null); setScreen("intro"); }}>
{t("takeAnother")}
);
}
return null;
}
const keyframes = `
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter:wght@400;500;600&display=swap');
@keyframes canaPulse { 0%,100%{ box-shadow:0 0 0 0 rgba(111,162,135,0.35);} 50%{ box-shadow:0 0 0 18px rgba(111,162,135,0);} }
@keyframes breathe { 0%,100%{ transform:scale(0.8); opacity:0.6;} 50%{ transform:scale(1.15); opacity:1;} }
@keyframes fadeInSoft { 0%{ opacity:0; transform:translateY(8px);} 100%{ opacity:1; transform:translateY(0);} }
@keyframes logoFade { 0%{ opacity:0; transform:scale(0.92);} 100%{ opacity:1; transform:scale(1);} }
/* ── Orbe holográfico: capas de color VIVAS que rotan y cambian de tono ── */
.holoLayer {
position: absolute; inset: 0; border-radius: 50%;
mix-blend-mode: screen; filter: blur(5px);
}
.holoA {
background: radial-gradient(circle at 40% 40%, #1D6FFF 0%, #3B82F6 45%, transparent 68%);
animation: holoShiftA 14s ease-in-out infinite, holoSpin 30s linear infinite;
}
.holoB {
background: radial-gradient(circle at 62% 52%, #FF2D55 0%, #FF6B9D 40%, transparent 65%);
animation: holoShiftB 18s ease-in-out infinite, holoSpin 40s linear infinite reverse;
}
.holoC {
background: radial-gradient(circle at 50% 66%, #00E676 0%, #22C55E 42%, transparent 66%);
animation: holoShiftC 16s ease-in-out infinite, holoSpin 34s linear infinite;
}
/* transiciona por azul → rojo → verde → blanco y mezclas, con más saturación y brillo */
@keyframes holoShiftA { 0%,100%{ filter:blur(5px) hue-rotate(0deg) saturate(1.7) brightness(1.3);} 50%{ filter:blur(7px) hue-rotate(180deg) saturate(2) brightness(1.4);} }
@keyframes holoShiftB { 0%,100%{ filter:blur(6px) hue-rotate(90deg) saturate(1.8) brightness(1.35);} 50%{ filter:blur(5px) hue-rotate(300deg) saturate(2.1) brightness(1.45);} }
@keyframes holoShiftC { 0%,100%{ filter:blur(5px) hue-rotate(220deg) saturate(1.7) brightness(1.3);} 50%{ filter:blur(8px) hue-rotate(40deg) saturate(2) brightness(1.4);} }
@keyframes holoSpin { from{ transform:rotate(0deg);} to{ transform:rotate(360deg);} }
input[type=range]{ -webkit-appearance:none; height:4px; background:${PALETTE.line}; border-radius:99px; }
input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; width:22px; height:22px; border-radius:50%; background:${PALETTE.bone}; border:3px solid ${PALETTE.sage}; cursor:pointer; }
input[type=range]::-moz-range-thumb{ width:22px; height:22px; border-radius:50%; background:${PALETTE.bone}; border:3px solid ${PALETTE.sage}; cursor:pointer; }
* { box-sizing:border-box; }
@media (prefers-reduced-motion: reduce){ *{ animation:none !important; transition:none !important; } }
`;