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>
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Cinzel:wght@300;400&display=swap' );
*, *::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 · 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 =>
);
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;
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.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>