641 lines
21 KiB
HTML
641 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>go_emotions Gradient Space - OKLab Edition</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
padding: 20px;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
background: #1a1a1a;
|
|
color: #fff;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
.container {
|
|
max-width: 1600px;
|
|
margin: 0 auto;
|
|
display: grid;
|
|
grid-template-columns: 1fr 350px;
|
|
gap: 20px;
|
|
height: calc(100vh - 40px);
|
|
}
|
|
.main-area {
|
|
min-width: 0;
|
|
overflow-y: auto;
|
|
}
|
|
.controls {
|
|
background: #2a2a2a;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
max-height: calc(100vh - 40px);
|
|
overflow-y: auto;
|
|
}
|
|
h1 {
|
|
margin-bottom: 10px;
|
|
font-size: 24px;
|
|
}
|
|
.subtitle {
|
|
margin-bottom: 20px;
|
|
color: #aaa;
|
|
font-size: 14px;
|
|
}
|
|
canvas {
|
|
display: block;
|
|
margin: 20px auto;
|
|
border: 1px solid #444;
|
|
cursor: crosshair;
|
|
touch-action: none;
|
|
background: #000;
|
|
}
|
|
canvas.dragging {
|
|
cursor: move !important;
|
|
}
|
|
canvas.hovering {
|
|
cursor: grab;
|
|
}
|
|
.info {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: #2a2a2a;
|
|
border-radius: 8px;
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
}
|
|
.weights {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
gap: 5px;
|
|
margin-top: 10px;
|
|
}
|
|
.weight-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
.weight-bar {
|
|
height: 4px;
|
|
background: #555;
|
|
margin-top: 2px;
|
|
}
|
|
.weight-fill {
|
|
height: 100%;
|
|
background: #4FC3F7;
|
|
}
|
|
.emotion-control {
|
|
margin-bottom: 15px;
|
|
padding: 10px;
|
|
background: #1a1a1a;
|
|
border-radius: 4px;
|
|
}
|
|
.emotion-control label {
|
|
display: block;
|
|
font-size: 12px;
|
|
margin-bottom: 5px;
|
|
text-transform: capitalize;
|
|
}
|
|
.emotion-control input[type="color"] {
|
|
width: 100%;
|
|
height: 30px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.export-btn {
|
|
width: 100%;
|
|
padding: 12px;
|
|
background: #4FC3F7;
|
|
color: #000;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.export-btn:hover {
|
|
background: #6FD3FF;
|
|
}
|
|
.controls h2 {
|
|
font-size: 16px;
|
|
margin-bottom: 15px;
|
|
}
|
|
.hint {
|
|
font-size: 11px;
|
|
color: #888;
|
|
margin-top: 5px;
|
|
}
|
|
.loading-spinner {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
z-index: 1000;
|
|
display: none;
|
|
}
|
|
.loading-spinner.active {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.spinner-circle {
|
|
width: 50px;
|
|
height: 50px;
|
|
border: 4px solid rgba(79, 195, 247, 0.2);
|
|
border-top-color: #4FC3F7;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
.spinner-text {
|
|
color: #4FC3F7;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
.loading-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.3);
|
|
z-index: 999;
|
|
display: none;
|
|
}
|
|
.loading-overlay.active {
|
|
display: block;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="loading-overlay" id="loadingOverlay"></div>
|
|
<div class="loading-spinner" id="loadingSpinner">
|
|
<div class="spinner-circle"></div>
|
|
<div class="spinner-text">Calculating gradient...</div>
|
|
</div>
|
|
<div class="container">
|
|
<div class="main-area">
|
|
<h1>go_emotions Gradient Space - OKLab Edition</h1>
|
|
<div class="subtitle">Drag centroids to reposition emotions. Colors blend in perceptually uniform OKLab space.</div>
|
|
<canvas id="gradientCanvas" width="800" height="800"></canvas>
|
|
<div class="info">
|
|
<div>Hover to see emotion weights | Click and drag centroids to move</div>
|
|
<div id="coordinates" style="margin-top: 5px;">Position: (-, -)</div>
|
|
<div class="weights" id="weights"></div>
|
|
</div>
|
|
</div>
|
|
<div class="controls">
|
|
<button class="export-btn" onclick="exportConfiguration()">Export Configuration</button>
|
|
<h2>Emotion Colors</h2>
|
|
<div class="hint">Click to edit colors for each emotion</div>
|
|
<div id="colorControls"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// OKLab color space conversion functions
|
|
// sRGB to Linear RGB
|
|
function srgbToLinear(c) {
|
|
const abs = Math.abs(c);
|
|
if (abs <= 0.04045) {
|
|
return c / 12.92;
|
|
}
|
|
return Math.sign(c) * Math.pow((abs + 0.055) / 1.055, 2.4);
|
|
}
|
|
|
|
// Linear RGB to sRGB
|
|
function linearToSrgb(c) {
|
|
const abs = Math.abs(c);
|
|
if (abs <= 0.0031308) {
|
|
return c * 12.92;
|
|
}
|
|
return Math.sign(c) * (1.055 * Math.pow(abs, 1 / 2.4) - 0.055);
|
|
}
|
|
|
|
// RGB (0-255) to OKLab
|
|
function rgbToOklab(r, g, b) {
|
|
// Normalize to 0-1
|
|
r = r / 255;
|
|
g = g / 255;
|
|
b = b / 255;
|
|
|
|
// Convert to linear RGB
|
|
r = srgbToLinear(r);
|
|
g = srgbToLinear(g);
|
|
b = srgbToLinear(b);
|
|
|
|
// Linear RGB to LMS
|
|
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
|
|
// LMS to OKLab
|
|
const l_ = Math.cbrt(l);
|
|
const m_ = Math.cbrt(m);
|
|
const s_ = Math.cbrt(s);
|
|
|
|
return {
|
|
L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
|
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
|
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
|
|
};
|
|
}
|
|
|
|
// OKLab to RGB (0-255)
|
|
function oklabToRgb(L, a, b) {
|
|
// OKLab to LMS
|
|
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
|
|
const l = l_ * l_ * l_;
|
|
const m = m_ * m_ * m_;
|
|
const s = s_ * s_ * s_;
|
|
|
|
// LMS to linear RGB
|
|
let r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
let b_ = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
|
|
// Linear RGB to sRGB
|
|
r = linearToSrgb(r);
|
|
g = linearToSrgb(g);
|
|
b_ = linearToSrgb(b_);
|
|
|
|
// Clamp and convert to 0-255
|
|
r = Math.max(0, Math.min(1, r)) * 255;
|
|
g = Math.max(0, Math.min(1, g)) * 255;
|
|
b_ = Math.max(0, Math.min(1, b_)) * 255;
|
|
|
|
return [r, g, b_];
|
|
}
|
|
|
|
const emotions = [
|
|
{ name: 'admiration', color: [255, 107, 107] },
|
|
{ name: 'amusement', color: [255, 217, 61] },
|
|
{ name: 'anger', color: [211, 47, 47] },
|
|
{ name: 'annoyance', color: [245, 124, 0] },
|
|
{ name: 'approval', color: [102, 187, 106] },
|
|
{ name: 'caring', color: [255, 182, 193] },
|
|
{ name: 'confusion', color: [156, 39, 176] },
|
|
{ name: 'curiosity', color: [79, 195, 247] },
|
|
{ name: 'desire', color: [233, 30, 99] },
|
|
{ name: 'disappointment', color: [109, 76, 65] },
|
|
{ name: 'disapproval', color: [139, 69, 19] },
|
|
{ name: 'disgust', color: [85, 139, 47] },
|
|
{ name: 'embarrassment', color: [255, 152, 0] },
|
|
{ name: 'excitement', color: [255, 241, 118] },
|
|
{ name: 'fear', color: [66, 66, 66] },
|
|
{ name: 'gratitude', color: [255, 224, 130] },
|
|
{ name: 'grief', color: [55, 71, 79] },
|
|
{ name: 'joy', color: [255, 235, 59] },
|
|
{ name: 'love', color: [255, 64, 129] },
|
|
{ name: 'nervousness', color: [126, 87, 194] },
|
|
{ name: 'optimism', color: [129, 199, 132] },
|
|
{ name: 'pride', color: [255, 213, 79] },
|
|
{ name: 'realization', color: [77, 208, 225] },
|
|
{ name: 'relief', color: [174, 213, 129] },
|
|
{ name: 'remorse', color: [186, 104, 200] },
|
|
{ name: 'sadness', color: [92, 107, 192] },
|
|
{ name: 'surprise', color: [255, 111, 0] },
|
|
{ name: 'neutral', color: [144, 164, 174] }
|
|
];
|
|
|
|
const canvas = document.getElementById('gradientCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const radius = Math.min(width, height) * 0.4;
|
|
|
|
// Clear canvas to black initially
|
|
ctx.fillStyle = '#000000';
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
// Position emotions in a circle
|
|
emotions.forEach((emotion, i) => {
|
|
const angle = (i / emotions.length) * Math.PI * 2;
|
|
emotion.x = centerX + Math.cos(angle) * radius;
|
|
emotion.y = centerY + Math.sin(angle) * radius;
|
|
});
|
|
|
|
// Dragging state
|
|
let draggedEmotion = null;
|
|
let isDragging = false;
|
|
let gradientImageData = null;
|
|
let animationFrameId = null;
|
|
let pendingUpdate = false;
|
|
|
|
// Initialize color controls
|
|
function initColorControls() {
|
|
const controlsDiv = document.getElementById('colorControls');
|
|
emotions.forEach((emotion, idx) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'emotion-control';
|
|
|
|
const label = document.createElement('label');
|
|
label.textContent = emotion.name;
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'color';
|
|
input.id = `color-${idx}`;
|
|
const hexColor = `#${emotion.color.map(c => Math.round(c).toString(16).padStart(2, '0')).join('')}`;
|
|
input.value = hexColor;
|
|
|
|
const updateColor = (e) => {
|
|
const hex = e.target.value;
|
|
const r = parseInt(hex.substring(1, 3), 16);
|
|
const g = parseInt(hex.substring(3, 5), 16);
|
|
const b = parseInt(hex.substring(5, 7), 16);
|
|
emotions[idx].color = [r, g, b];
|
|
redrawGradient();
|
|
};
|
|
|
|
input.addEventListener('input', updateColor);
|
|
input.addEventListener('change', updateColor);
|
|
|
|
div.appendChild(label);
|
|
div.appendChild(input);
|
|
controlsDiv.appendChild(div);
|
|
});
|
|
}
|
|
|
|
// Loading indicator helpers
|
|
function showLoading() {
|
|
document.getElementById('loadingOverlay').classList.add('active');
|
|
document.getElementById('loadingSpinner').classList.add('active');
|
|
}
|
|
|
|
function hideLoading() {
|
|
document.getElementById('loadingOverlay').classList.remove('active');
|
|
document.getElementById('loadingSpinner').classList.remove('active');
|
|
}
|
|
|
|
// Calculate and cache the gradient
|
|
function calculateGradient() {
|
|
const imageData = ctx.createImageData(width, height);
|
|
const data = imageData.data;
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const idx = (y * width + x) * 4;
|
|
|
|
// Calculate weights using inverse distance
|
|
let totalWeight = 0;
|
|
const weights = [];
|
|
|
|
emotions.forEach(emotion => {
|
|
const dx = x - emotion.x;
|
|
const dy = y - emotion.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
const weight = 1 / (Math.pow(dist, 2.5) + 1);
|
|
weights.push(weight);
|
|
totalWeight += weight;
|
|
});
|
|
|
|
// Normalize weights and blend colors in OKLab space
|
|
let L = 0, a = 0, b = 0;
|
|
weights.forEach((weight, i) => {
|
|
const normalizedWeight = weight / totalWeight;
|
|
const lab = rgbToOklab(...emotions[i].color);
|
|
L += lab.L * normalizedWeight;
|
|
a += lab.a * normalizedWeight;
|
|
b += lab.b * normalizedWeight;
|
|
});
|
|
|
|
// Convert back to RGB
|
|
const [r, g, b_] = oklabToRgb(L, a, b);
|
|
|
|
data[idx] = r;
|
|
data[idx + 1] = g;
|
|
data[idx + 2] = b_;
|
|
data[idx + 3] = 255;
|
|
}
|
|
}
|
|
|
|
gradientImageData = imageData;
|
|
}
|
|
|
|
// Redraw the entire gradient
|
|
function redrawGradient() {
|
|
showLoading();
|
|
// Use setTimeout to allow the loading spinner to render before blocking
|
|
setTimeout(() => {
|
|
calculateGradient();
|
|
renderCanvas();
|
|
hideLoading();
|
|
}, 50);
|
|
}
|
|
|
|
// Render the canvas (gradient + points)
|
|
function renderCanvas() {
|
|
ctx.putImageData(gradientImageData, 0, 0);
|
|
drawEmotionPoints();
|
|
}
|
|
|
|
// Schedule a render using requestAnimationFrame
|
|
function scheduleRender() {
|
|
if (!pendingUpdate) {
|
|
pendingUpdate = true;
|
|
requestAnimationFrame(() => {
|
|
renderCanvas();
|
|
pendingUpdate = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Draw emotion labels and centroids
|
|
function drawEmotionPoints() {
|
|
ctx.font = '12px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
emotions.forEach((emotion, i) => {
|
|
// Draw a larger circle at each emotion point for better dragging
|
|
ctx.fillStyle = `rgb(${emotion.color[0]}, ${emotion.color[1]}, ${emotion.color[2]})`;
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(emotion.x, emotion.y, 8, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// Draw label with background
|
|
const dx = emotion.x - centerX;
|
|
const dy = emotion.y - centerY;
|
|
const angle = Math.atan2(dy, dx);
|
|
const labelRadius = Math.sqrt(dx * dx + dy * dy) + 30;
|
|
const labelX = centerX + Math.cos(angle) * labelRadius;
|
|
const labelY = centerY + Math.sin(angle) * labelRadius;
|
|
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
const textWidth = ctx.measureText(emotion.name).width;
|
|
ctx.fillRect(labelX - textWidth/2 - 3, labelY - 8, textWidth + 6, 16);
|
|
|
|
ctx.fillStyle = '#fff';
|
|
ctx.fillText(emotion.name, labelX, labelY);
|
|
});
|
|
}
|
|
|
|
// Mouse event handlers
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
|
|
// Check if clicking on any emotion centroid (larger hit area for easier clicking)
|
|
for (const emotion of emotions) {
|
|
const dx = x - emotion.x;
|
|
const dy = y - emotion.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < 25) { // Increased from 15 to 25 for easier clicking
|
|
draggedEmotion = emotion;
|
|
isDragging = true;
|
|
canvas.classList.add('dragging');
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
|
|
if (isDragging && draggedEmotion) {
|
|
e.preventDefault();
|
|
draggedEmotion.x = Math.max(0, Math.min(width, x));
|
|
draggedEmotion.y = Math.max(0, Math.min(height, y));
|
|
// Use requestAnimationFrame for smooth updates
|
|
scheduleRender();
|
|
} else {
|
|
// Check if hovering over any centroid
|
|
let isHovering = false;
|
|
for (const emotion of emotions) {
|
|
const dx = x - emotion.x;
|
|
const dy = y - emotion.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < 25) {
|
|
isHovering = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Update cursor
|
|
if (isHovering) {
|
|
canvas.classList.add('hovering');
|
|
} else {
|
|
canvas.classList.remove('hovering');
|
|
}
|
|
|
|
showWeights(Math.floor(x), Math.floor(y));
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', () => {
|
|
if (isDragging) {
|
|
// Recalculate gradient when drag ends
|
|
redrawGradient();
|
|
}
|
|
isDragging = false;
|
|
draggedEmotion = null;
|
|
canvas.classList.remove('dragging');
|
|
canvas.classList.remove('hovering');
|
|
});
|
|
|
|
canvas.addEventListener('mouseleave', () => {
|
|
if (isDragging) {
|
|
// Recalculate gradient when drag ends
|
|
redrawGradient();
|
|
}
|
|
isDragging = false;
|
|
draggedEmotion = null;
|
|
canvas.classList.remove('dragging');
|
|
canvas.classList.remove('hovering');
|
|
});
|
|
|
|
// Interactive hover/click
|
|
function showWeights(x, y) {
|
|
const coordDiv = document.getElementById('coordinates');
|
|
const weightsDiv = document.getElementById('weights');
|
|
|
|
coordDiv.textContent = `Position: (${x}, ${y})`;
|
|
|
|
// Calculate weights for this position
|
|
let totalWeight = 0;
|
|
const weights = [];
|
|
|
|
emotions.forEach(emotion => {
|
|
const dx = x - emotion.x;
|
|
const dy = y - emotion.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
const weight = 1 / (Math.pow(dist, 2.5) + 1);
|
|
weights.push(weight);
|
|
totalWeight += weight;
|
|
});
|
|
|
|
// Sort by weight descending
|
|
const sortedEmotions = emotions.map((e, i) => ({
|
|
name: e.name,
|
|
weight: weights[i] / totalWeight
|
|
})).sort((a, b) => b.weight - a.weight);
|
|
|
|
weightsDiv.innerHTML = sortedEmotions
|
|
.filter(e => e.weight > 0.01)
|
|
.map(e => `
|
|
<div>
|
|
<div class="weight-item">
|
|
<span>${e.name}</span>
|
|
<span>${(e.weight * 100).toFixed(1)}%</span>
|
|
</div>
|
|
<div class="weight-bar">
|
|
<div class="weight-fill" style="width: ${e.weight * 100}%"></div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Export configuration
|
|
function exportConfiguration() {
|
|
const config = {
|
|
colorSpace: 'oklab',
|
|
canvasSize: { width, height },
|
|
emotions: emotions.map(e => ({
|
|
name: e.name,
|
|
position: { x: e.x, y: e.y },
|
|
color: { r: e.color[0], g: e.color[1], b: e.color[2] }
|
|
})),
|
|
metadata: {
|
|
exportDate: new Date().toISOString(),
|
|
version: '1.0'
|
|
}
|
|
};
|
|
|
|
const dataStr = JSON.stringify(config, null, 2);
|
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(dataBlob);
|
|
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `emotion-gradient-config-${Date.now()}.json`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
console.log('Configuration exported:', config);
|
|
}
|
|
|
|
// Initialize
|
|
initColorControls();
|
|
redrawGradient();
|
|
</script>
|
|
</body>
|
|
</html>
|