Collection

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emotional Cartography — Kel Otero Art</title>
<style>

  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  :root {
    --stone: #1a1714;
    --stone-mid: #2a2520;
    --stone-light: #3d3730;
    --ink: #0d0b09;
    --gold: #c8a96e;
    --gold-dim: #8a6e3e;
    --ash: #6b6358;
    --cream: #e8dfd0;
    --mist: rgba(200, 169, 110, 0.08);
  }

  html, body {
    width: 100%; height: 100%;
    background: var(--ink);
    overflow: hidden;
    font-family: 'Cormorant Garamond', serif;
    cursor: crosshair;
  }

  #canvas-container {
    position: relative;
    width: 100vw;
    height: 100vh;
  }

  canvas#field {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
  }

  /* Nodes */
  .emotion-node {
    position: absolute;
    transform: translate(-50%, -50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    z-index: 10;
    transition: none;
    pointer-events: all;
  }

  .node-ring {
    position: relative;
    width: 64px;
    height: 64px;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .node-ring::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    border: 1px solid rgba(200, 169, 110, 0.25);
    transition: all 0.6s cubic-bezier(0.23, 1, 0.32, 1);
  }

  .node-ring::after {
    content: '';
    position: absolute;
    inset: 8px;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(200,169,110,0.15) 0%, transparent 70%);
    transition: all 0.6s cubic-bezier(0.23, 1, 0.32, 1);
  }

  .node-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--gold-dim);
    position: relative;
    z-index: 2;
    transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
    box-shadow: 0 0 12px rgba(200, 169, 110, 0.3);
  }

  .node-label {
    margin-top: 6px;
    font-family: 'Cinzel', serif;
    font-size: 9px;
    font-weight: 300;
    letter-spacing: 0.22em;
    color: var(--ash);
    text-transform: uppercase;
    text-align: center;
    transition: all 0.5s ease;
    white-space: nowrap;
  }

  .node-song {
    font-family: 'Cormorant Garamond', serif;
    font-style: italic;
    font-size: 9px;
    color: rgba(200, 169, 110, 0.3);
    letter-spacing: 0.08em;
    margin-top: 2px;
    text-align: center;
    white-space: nowrap;
    transition: all 0.5s ease;
    max-width: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* Active / hovered states */
  .emotion-node.active .node-ring::before {
    border-color: rgba(200, 169, 110, 0.9);
    inset: -6px;
    box-shadow: 0 0 30px rgba(200, 169, 110, 0.2), inset 0 0 20px rgba(200,169,110,0.05);
  }

  .emotion-node.active .node-ring::after {
    background: radial-gradient(circle, rgba(200,169,110,0.35) 0%, transparent 70%);
    inset: 0px;
  }

  .emotion-node.active .node-dot {
    background: var(--gold);
    width: 12px;
    height: 12px;
    box-shadow: 0 0 24px rgba(200, 169, 110, 0.7), 0 0 60px rgba(200, 169, 110, 0.2);
  }

  .emotion-node.active .node-label {
    color: var(--cream);
  }

  .emotion-node.active .node-song {
    color: rgba(200, 169, 110, 0.8);
  }

  /* Shadow state — adjacent emotions */
  .emotion-node.shadow {
    filter: none;
  }

  .emotion-node.shadow .node-ring::before {
    border-color: rgba(200, 169, 110, 0.45);
    box-shadow: 0 0 16px rgba(200, 169, 110, 0.08);
  }

  .emotion-node.shadow .node-dot {
    background: #8a7a5a;
    box-shadow: 0 0 16px rgba(200, 169, 110, 0.4);
    width: 10px;
    height: 10px;
  }

  .emotion-node.shadow .node-label {
    color: #b8a880;
  }

  .emotion-node.shadow .node-song {
    color: rgba(200, 169, 110, 0.5);
  }

  /* Pulse animations */
  @keyframes pulse-ring {
    0% { transform: scale(1); opacity: 0.8; }
    100% { transform: scale(2.2); opacity: 0; }
  }

  .pulse-ring {
    position: absolute;
    inset: 0;
    border-radius: 50%;
    border: 1px solid rgba(200, 169, 110, 0.6);
    animation: pulse-ring 1.8s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
    pointer-events: none;
  }

  .pulse-ring:nth-child(2) { animation-delay: 0.6s; }

  /* Tooltip / detail panel */
  #detail-panel {
    position: fixed;
    bottom: 40px;
    left: 50%;
    transform: translateX(-50%);
    text-align: center;
    pointer-events: none;
    z-index: 100;
    transition: opacity 0.6s ease;
    opacity: 0;
    max-width: 460px;
    padding: 0 24px;
  }

  #detail-panel.visible { opacity: 1; }

  #detail-emotion {
    font-family: 'Cinzel', serif;
    font-size: 11px;
    letter-spacing: 0.3em;
    color: var(--gold);
    text-transform: uppercase;
    margin-bottom: 8px;
  }

  #detail-text {
    font-family: 'Cormorant Garamond', serif;
    font-size: 16px;
    font-weight: 300;
    line-height: 1.7;
    color: rgba(232, 223, 208, 0.75);
    font-style: italic;
  }

  #detail-divider {
    width: 40px;
    height: 1px;
    background: linear-gradient(90deg, transparent, var(--gold-dim), transparent);
    margin: 12px auto;
  }

  /* Header */
  #header {
    position: fixed;
    top: 32px;
    left: 50%;
    transform: translateX(-50%);
    text-align: center;
    z-index: 100;
    pointer-events: none;
  }

  #header h1 {
    font-family: 'Cinzel', serif;
    font-size: 11px;
    font-weight: 300;
    letter-spacing: 0.5em;
    color: rgba(200, 169, 110, 0.5);
    text-transform: uppercase;
  }

  #header p {
    font-family: 'Cormorant Garamond', serif;
    font-size: 11px;
    font-style: italic;
    color: rgba(107, 99, 88, 0.6);
    margin-top: 4px;
    letter-spacing: 0.1em;
  }

  /* Grain overlay */
  #grain {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 200;
    opacity: 0.035;
    background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
    background-size: 256px 256px;
  }
</style>
</head>
<body>

<div id="grain"></div>

<div id="header">
  <h1>Kel Otero Art &nbsp;·&nbsp; Survival Mode</h1>
  <p>hover a feeling — watch it move through the field</p>
</div>

<div id="canvas-container">
  <canvas id="field"></canvas>
</div>

<div id="detail-panel">
  <div id="detail-emotion"></div>
  <div id="detail-divider"></div>
  <div id="detail-text"></div>
</div>

<script>
// ──────────────────────────────────────────────
// DATA
// ──────────────────────────────────────────────
const EMOTIONS = [
  {
    id: 'connection',
    label: 'Connection',
    song: null,
    text: 'The thread that runs between all things — the reason we reach out across the dark.',
    shadow: ['vulnerability', 'compassion', 'awe'],
    color: [200, 169, 110],
    // position as fraction of viewport [x, y]
    pos: [0.5, 0.18]
  },
  {
    id: 'despair',
    label: 'Despair',
    song: null,
    text: 'The weight that teaches us what we can no longer carry — and what we must set down.',
    shadow: ['flooding', 'heartbreak', 'bittersweet'],
    color: [110, 130, 160],
    pos: [0.18, 0.32]
  },
  {
    id: 'flooding',
    label: 'Flooding',
    song: null,
    text: 'When emotion overwhelms the banks we built to hold it. The water always finds a way.',
    shadow: ['despair', 'vulnerability', 'curiosity'],
    color: [90, 130, 180],
    pos: [0.12, 0.55]
  },
  {
    id: 'curiosity',
    label: 'Curiosity',
    song: null,
    text: 'The first light through a crack — the willingness to look before we understand.',
    shadow: ['flooding', 'awe', 'contentment'],
    color: [160, 200, 170],
    pos: [0.22, 0.76]
  },
  {
    id: 'vulnerability',
    label: 'Vulnerability',
    song: 'What\'s Going On — Marvin Gaye',
    text: 'Not weakness — our greatest measure of courage. Uncertainty held open as an act of love.',
    shadow: ['connection', 'compassion', 'heartbreak'],
    color: [200, 150, 130],
    pos: [0.36, 0.42]
  },
  {
    id: 'compassion',
    label: 'Compassion',
    song: null,
    text: 'The only way is through the emotion. Learn from it. Heal from it. Move through it.',
    shadow: ['vulnerability', 'connection', 'bittersweet'],
    color: [210, 180, 140],
    pos: [0.5, 0.5]
  },
  {
    id: 'heartbreak',
    label: 'Heartbreak',
    song: 'When Doves Cry — Prince',
    text: 'It hurts different because heartbreak is always connected to love and belonging.',
    shadow: ['despair', 'vulnerability', 'bittersweet'],
    color: [200, 100, 120],
    pos: [0.64, 0.42]
  },
  {
    id: 'awe',
    label: 'Awe',
    song: 'A Change Is Gonna Come — Sam Cooke',
    text: 'A sense of hope. Wonder inspires the wish to understand. To simply stand back — and let shine.',
    shadow: ['connection', 'curiosity', 'contentment'],
    color: [150, 190, 220],
    pos: [0.78, 0.55]
  },
  {
    id: 'bittersweet',
    label: 'Bittersweet',
    song: null,
    text: 'Where grief and gratitude share the same breath — the taste of something beautiful passing.',
    shadow: ['despair', 'compassion', 'heartbreak', 'contentment'],
    color: [180, 155, 190],
    pos: [0.5, 0.72]
  },
  {
    id: 'contentment',
    label: 'Contentment',
    song: 'If You Don\'t Know Now You Know — Notorious B.I.G.',
    text: 'Deep appreciation for what brings meaning — the quiet recognition of having arrived.',
    shadow: ['curiosity', 'awe', 'bittersweet'],
    color: [200, 210, 170],
    pos: [0.82, 0.76]
  },
  {
    id: 'gratitude',
    label: 'Gratitude',
    song: null,
    text: 'The emotion that reflects what we value, what makes us feel connected to ourselves and others.',
    shadow: ['contentment', 'compassion', 'connection'],
    color: [220, 200, 140],
    pos: [0.82, 0.30]
  },
  {
    id: 'wonder',
    label: 'Wonder',
    song: null,
    text: 'Fuels our passion for exploration — curiosity and adventure made luminous.',
    shadow: ['awe', 'curiosity', 'connection'],
    color: [170, 200, 230],
    pos: [0.36, 0.22]
  }
];

// ──────────────────────────────────────────────
// CANVAS FIELD
// ──────────────────────────────────────────────
const canvas = document.getElementById('field');
const ctx = canvas.getContext('2d');
let W, H, particles = [], connections = [], activeId = null;

function resize() {
  W = canvas.width = window.innerWidth;
  H = canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', () => { resize(); buildConnections(); });

// Convert fractional positions to px
function px(emotion) {
  return { x: emotion.pos[0] * W, y: emotion.pos[1] * H };
}

// Build connection pairs
function buildConnections() {
  connections = [];
  EMOTIONS.forEach(e => {
    e.shadow.forEach(sid => {
      const exists = connections.find(c =>
        (c.a === e.id && c.b === sid) || (c.a === sid && c.b === e.id)
      );
      if (!exists) connections.push({ a: e.id, b: sid, alpha: 0, targetAlpha: 0 });
    });
  });
}
buildConnections();

// Particles floating through the field
function initParticles() {
  particles = [];
  const count = Math.floor(W * H / 14000);
  for (let i = 0; i < count; i++) {
    particles.push({
      x: Math.random() * W,
      y: Math.random() * H,
      vx: (Math.random() - 0.5) * 0.12,
      vy: (Math.random() - 0.5) * 0.08,
      size: Math.random() * 1.5 + 0.4,
      alpha: Math.random() * 0.15 + 0.02,
      baseAlpha: 0,
    });
    particles[particles.length - 1].baseAlpha = particles[particles.length - 1].alpha;
  }
}
initParticles();

// Ripple objects
let ripples = [];

function addRipple(x, y, color) {
  ripples.push({ x, y, r: 0, maxR: Math.max(W, H) * 0.6, alpha: 0.35, color });
}

// ──────────────────────────────────────────────
// RENDER LOOP
// ──────────────────────────────────────────────
let frame = 0;

function draw() {
  frame++;
  ctx.clearRect(0, 0, W, H);

  // Stone base gradient
  const bg = ctx.createRadialGradient(W * 0.5, H * 0.45, 0, W * 0.5, H * 0.45, W * 0.72);
  bg.addColorStop(0, '#201c18');
  bg.addColorStop(1, '#0d0b09');
  ctx.fillStyle = bg;
  ctx.fillRect(0, 0, W, H);

  // Update and draw ripples
  ripples = ripples.filter(r => r.alpha > 0.005);
  ripples.forEach(r => {
    r.r += 2.8;
    r.alpha *= 0.975;
    ctx.beginPath();
    ctx.arc(r.x, r.y, r.r, 0, Math.PI * 2);
    ctx.strokeStyle = `rgba(${r.color[0]},${r.color[1]},${r.color[2]},${r.alpha * 0.5})`;
    ctx.lineWidth = 1;
    ctx.stroke();
  });

  // Draw connections
  connections.forEach(c => {
    const eA = EMOTIONS.find(e => e.id === c.a);
    const eB = EMOTIONS.find(e => e.id === c.b);
    if (!eA || !eB) return;

    const isActive = activeId && (c.a === activeId || c.b === activeId);
    c.targetAlpha = isActive ? 0.55 : 0.07;
    c.alpha += (c.targetAlpha - c.alpha) * 0.06;

    const pA = px(eA), pB = px(eB);
    const grad = ctx.createLinearGradient(pA.x, pA.y, pB.x, pB.y);
    grad.addColorStop(0, `rgba(${eA.color[0]},${eA.color[1]},${eA.color[2]},${c.alpha})`);
    grad.addColorStop(1, `rgba(${eB.color[0]},${eB.color[1]},${eB.color[2]},${c.alpha})`);

    ctx.beginPath();
    // Slightly curved lines
    const mx = (pA.x + pB.x) / 2 + (Math.sin(frame * 0.004 + eA.pos[0] * 10) * 8);
    const my = (pA.y + pB.y) / 2 + (Math.cos(frame * 0.003 + eB.pos[1] * 10) * 8);
    ctx.moveTo(pA.x, pA.y);
    ctx.quadraticCurveTo(mx, my, pB.x, pB.y);
    ctx.strokeStyle = grad;
    ctx.lineWidth = isActive ? 1.2 : 0.5;
    ctx.stroke();
  });

  // Draw particles
  particles.forEach(p => {
    p.x += p.vx + Math.sin(frame * 0.005 + p.y * 0.01) * 0.05;
    p.y += p.vy + Math.cos(frame * 0.004 + p.x * 0.01) * 0.04;
    if (p.x < 0) p.x = W; if (p.x > W) p.x = 0;
    if (p.y < 0) p.y = H; if (p.y > H) p.y = 0;

    // Boost particles near active node
    if (activeId) {
      const ae = EMOTIONS.find(e => e.id === activeId);
      if (ae) {
        const ap = px(ae);
        const dx = p.x - ap.x, dy = p.y - ap.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        if (dist < 200) {
          p.alpha = p.baseAlpha + (1 - dist / 200) * 0.25;
        } else {
          p.alpha += (p.baseAlpha - p.alpha) * 0.05;
        }
      }
    } else {
      p.alpha += (p.baseAlpha - p.alpha) * 0.05;
    }

    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(200,169,110,${p.alpha})`;
    ctx.fill();
  });

  // Glow halos around emotion nodes (drawn on canvas under DOM nodes)
  EMOTIONS.forEach(e => {
    const p = px(e);
    const isActiveNode = e.id === activeId;
    const isShadow = activeId && EMOTIONS.find(a => a.id === activeId)?.shadow.includes(e.id);

    if (isActiveNode || isShadow) {
      const intensity = isActiveNode ? 1 : 0.4;
      const radius = isActiveNode ? 90 : 55;
      const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
      glow.addColorStop(0, `rgba(${e.color[0]},${e.color[1]},${e.color[2]},${0.14 * intensity})`);
      glow.addColorStop(1, `rgba(${e.color[0]},${e.color[1]},${e.color[2]},0)`);
      ctx.beginPath();
      ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
      ctx.fillStyle = glow;
      ctx.fill();

      // Subtle breathing pulse on active
      if (isActiveNode) {
        const pulse = 0.5 + 0.5 * Math.sin(frame * 0.04);
        const pr = 50 + pulse * 30;
        const pg = ctx.createRadialGradient(p.x, p.y, pr * 0.5, p.x, p.y, pr);
        pg.addColorStop(0, `rgba(${e.color[0]},${e.color[1]},${e.color[2]},0)`);
        pg.addColorStop(1, `rgba(${e.color[0]},${e.color[1]},${e.color[2]},${0.06 * pulse})`);
        ctx.beginPath();
        ctx.arc(p.x, p.y, pr, 0, Math.PI * 2);
        ctx.fillStyle = pg;
        ctx.fill();
      }
    }
  });

  requestAnimationFrame(draw);
}
draw();

// ──────────────────────────────────────────────
// DOM NODES
// ──────────────────────────────────────────────
const container = document.getElementById('canvas-container');
const nodeEls = {};

EMOTIONS.forEach(e => {
  const el = document.createElement('div');
  el.className = 'emotion-node';
  el.id = `node-${e.id}`;

  el.innerHTML = `
    <div class="node-ring">
      <div class="node-dot"></div>
    </div>
    <div class="node-label">${e.label}</div>
    ${e.song ? `<div class="node-song">${e.song}</div>` : ''}
  `;

  container.appendChild(el);
  nodeEls[e.id] = el;

  // Position
  function positionNode() {
    el.style.left = (e.pos[0] * 100) + '%';
    el.style.top = (e.pos[1] * 100) + '%';
  }
  positionNode();
  window.addEventListener('resize', positionNode);

  el.addEventListener('mouseenter', () => activateEmotion(e.id));
  el.addEventListener('mouseleave', () => deactivateEmotion());
  el.addEventListener('touchstart', (ev) => { ev.preventDefault(); activateEmotion(e.id); }, { passive: false });
  el.addEventListener('touchend', () => deactivateEmotion());
});

// ──────────────────────────────────────────────
// INTERACTION
// ──────────────────────────────────────────────
const panel = document.getElementById('detail-panel');
const detailEmotion = document.getElementById('detail-emotion');
const detailText = document.getElementById('detail-text');

function activateEmotion(id) {
  if (activeId === id) return;
  activeId = id;

  const e = EMOTIONS.find(em => em.id === id);
  const p = px(e);

  // Ripple
  addRipple(p.x, p.y, e.color);

  // Node states
  Object.entries(nodeEls).forEach(([nid, el]) => {
    el.classList.remove('active', 'shadow');
    if (nid === id) {
      el.classList.add('active');
      // Add pulse rings
      let rings = el.querySelectorAll('.pulse-ring');
      if (!rings.length) {
        const r1 = document.createElement('div');
        r1.className = 'pulse-ring';
        const r2 = document.createElement('div');
        r2.className = 'pulse-ring';
        el.querySelector('.node-ring').append(r1, r2);
      }
    } else if (e.shadow.includes(nid)) {
      el.classList.add('shadow');
    }
  });

  // Detail panel
  detailEmotion.textContent = e.label.toUpperCase();
  detailText.textContent = e.text + (e.song ? `  —  "${e.song}"` : '');
  panel.classList.add('visible');
}

function deactivateEmotion() {
  activeId = null;

  Object.values(nodeEls).forEach(el => {
    el.classList.remove('active', 'shadow');
    el.querySelectorAll('.pulse-ring').forEach(r => r.remove());
  });

  panel.classList.remove('visible');
}

// Gentle ambient mouse drift — stir the field
document.addEventListener('mousemove', (ev) => {
  // nudge nearby particles subtly
  const mx = ev.clientX, my = ev.clientY;
  particles.forEach(p => {
    const dx = p.x - mx, dy = p.y - my;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < 80) {
      p.vx += (dx / dist) * 0.04;
      p.vy += (dy / dist) * 0.04;
      // dampen
      p.vx *= 0.92;
      p.vy *= 0.92;
    }
  });
});
</script>
</body>
</html>