Skip to content
Merged
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
22 changes: 22 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Deploy to VPS

on:
push:
branches: [main, voice-to-graph]

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
export PATH=$PATH:/home/ian/.npm-global/bin
cd /home/ian/experiments/voice-to-graph
git pull origin ${{ github.ref_name }}
npm install --omit=dev
pm2 restart voice-to-graph
Binary file added public/img/placeholder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 39 additions & 12 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ai-to-graph</title>
<link rel="stylesheet" href="/style.css" />
<title>voice-to-graph</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
<script type="importmap">
{
"imports": {
Expand All @@ -16,19 +19,43 @@
</script>
</head>
<body>
<header id="bar">
<label class="file">
<input id="avatar-input" type="file" accept="image/*" hidden />
<span>Upload avatar</span>
</label>
<button id="toggle">Start</button>
<span id="status">idle</span>
</header>

<div id="graph"></div>
<div id="tip" hidden></div>
<audio id="remote" autoplay></audio>

<script type="module" src="/js/main.js"></script>
<input id="avatar-input" type="file" accept="image/*" hidden />

<!-- pencil overlay for avatar edit -->
<div id="avatar-edit" hidden>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
<path d="m15 5 4 4"/>
</svg>
</div>

<!-- bottom controls -->
<div id="controls">
<div id="status-pill">
<span id="status-dot"></span>
<span id="status">ready</span>
</div>
<button id="mic-btn" aria-label="Toggle microphone">
<div class="mic-rings">
<span class="ring ring-1"></span>
<span class="ring ring-2"></span>
<span class="ring ring-3"></span>
</div>
<svg class="mic-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1a4 4 0 0 0-4 4v6a4 4 0 0 0 8 0V5a4 4 0 0 0-4-4Z"/>
<path d="M19 10v1a7 7 0 0 1-14 0v-1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="19" x2="12" y2="23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<svg class="stop-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style="display:none">
<rect x="4" y="4" width="16" height="16" rx="3"/>
</svg>
</button>
</div>

<script type="module" src="js/main.js"></script>
</body>
</html>
14 changes: 1 addition & 13 deletions public/js/avatar.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,6 @@
const KEY = 'avatar';

const FALLBACK = 'data:image/svg+xml;utf8,' + encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<radialGradient id="g" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color="#6f86ff"/>
<stop offset="100%" stop-color="#1a2150"/>
</radialGradient>
</defs>
<circle cx="64" cy="64" r="64" fill="url(#g)"/>
<circle cx="64" cy="52" r="20" fill="#e8ecf3"/>
<path d="M24 112 C24 84 104 84 104 112 Z" fill="#e8ecf3"/>
</svg>
`);
const FALLBACK = 'img/placeholder.png';

export function getAvatar() {
try {
Expand Down
141 changes: 111 additions & 30 deletions public/js/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import ForceGraph3D from '3d-force-graph';
import * as THREE from 'three';
import { getAvatar } from './avatar.js';

const fontReady = document.fonts.ready;

let graph;
let tipEl;
let mouse = { x: 0, y: 0 };
Expand All @@ -12,19 +14,71 @@ let lastConceptId = null;
let fitTimer = null;
let branchCounters = {};
let nextBranchCharCode = 65;
let meHoverCb = null;

const AVATAR_TEXTURE_SIZE = 256;
const AVATAR_SPRITE_SIZE = 28;
const CONCEPT_NODE_RADIUS = 4;

function drawFallback(ctx, size) {
const grad = ctx.createRadialGradient(size / 2, size * 0.4, size * 0.1, size / 2, size / 2, size / 2);
grad.addColorStop(0, '#6f86ff');
grad.addColorStop(1, '#1a2150');
ctx.fillStyle = grad;
function drawFallbackAvatar(ctx, size) {
const cx = size / 2;

// dark circle background with subtle radial gradient
const bg = ctx.createRadialGradient(cx, cx, 0, cx, cx, cx);
bg.addColorStop(0, '#141e35');
bg.addColorStop(1, '#0a0f1c');
ctx.beginPath();
ctx.arc(cx, cx, cx, 0, Math.PI * 2);
ctx.fillStyle = bg;
ctx.fill();

// outer ring — thin glowing border
ctx.beginPath();
ctx.arc(cx, cx, cx - 2, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(80,140,255,0.2)';
ctx.lineWidth = 2;
ctx.stroke();

// clip everything inside the circle
ctx.save();
ctx.beginPath();
ctx.arc(cx, cx, cx - 2, 0, Math.PI * 2);
ctx.clip();

// head — soft glowing circle
const headR = size * 0.14;
const headY = size * 0.35;
const headGlow = ctx.createRadialGradient(cx, headY, 0, cx, headY, headR * 1.6);
headGlow.addColorStop(0, 'rgba(100,160,255,0.12)');
headGlow.addColorStop(1, 'transparent');
ctx.fillStyle = headGlow;
ctx.fillRect(0, 0, size, size);

ctx.beginPath();
ctx.arc(cx, headY, headR, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(120,170,255,0.35)';
ctx.fill();
ctx.strokeStyle = 'rgba(120,170,255,0.45)';
ctx.lineWidth = 1.5;
ctx.stroke();

// body — shoulders/torso arc
const bodyTop = size * 0.56;
const bodyGlow = ctx.createRadialGradient(cx, bodyTop + size * 0.12, 0, cx, bodyTop + size * 0.12, size * 0.35);
bodyGlow.addColorStop(0, 'rgba(100,160,255,0.1)');
bodyGlow.addColorStop(1, 'transparent');
ctx.fillStyle = bodyGlow;
ctx.fillRect(0, bodyTop - 20, size, size);

ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.ellipse(cx, bodyTop + size * 0.2, size * 0.3, size * 0.26, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(120,170,255,0.25)';
ctx.fill();
ctx.strokeStyle = 'rgba(120,170,255,0.35)';
ctx.lineWidth = 1.5;
ctx.stroke();

ctx.restore();
}

function getAvatarTexture(dataUrl) {
Expand All @@ -36,30 +90,34 @@ function getAvatarTexture(dataUrl) {
canvas.height = size;
const ctx = canvas.getContext('2d');

drawFallback(ctx, size);
// draw fallback immediately so there's never a blank frame
drawFallbackAvatar(ctx, size);

const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 8;

const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
const srcSize = Math.min(img.width, img.height);
const sx = (img.width - srcSize) / 2;
const sy = (img.height - srcSize) / 2;
ctx.drawImage(img, sx, sy, srcSize, srcSize, 0, 0, size, size);
ctx.restore();
texture.needsUpdate = true;
};
img.onerror = err => console.error('[graph] avatar image load failed', err);
img.src = dataUrl;
// if dataUrl is a real image (data: or uploaded), overlay it
if (dataUrl && !dataUrl.endsWith('.png')) {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
ctx.clearRect(0, 0, size, size);
ctx.save();
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
const srcSize = Math.min(img.width, img.height);
const sx = (img.width - srcSize) / 2;
const sy = (img.height - srcSize) / 2;
ctx.drawImage(img, sx, sy, srcSize, srcSize, 0, 0, size, size);
ctx.restore();
texture.needsUpdate = true;
};
img.onerror = () => {}; // fallback already drawn
img.src = dataUrl;
}

cachedAvatarUrl = dataUrl;
cachedAvatarTexture = texture;
Expand All @@ -71,7 +129,7 @@ function makeTextSprite(text) {
const fontSize = 44;
const measureCanvas = document.createElement('canvas');
const mctx = measureCanvas.getContext('2d');
mctx.font = `600 ${fontSize}px -apple-system, system-ui, sans-serif`;
mctx.font = `300 ${fontSize}px 'Sora', system-ui, sans-serif`;
const textWidth = Math.ceil(mctx.measureText(text).width);

const w = textWidth + pad * 2;
Expand All @@ -81,8 +139,7 @@ function makeTextSprite(text) {
canvas.height = h;
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'rgba(8,12,24,0.72)';
const r = 10;
const r = 12;
ctx.beginPath();
ctx.moveTo(r, 0);
ctx.lineTo(w - r, 0);
Expand All @@ -94,10 +151,14 @@ function makeTextSprite(text) {
ctx.lineTo(0, r);
ctx.quadraticCurveTo(0, 0, r, 0);
ctx.closePath();
ctx.fillStyle = 'rgba(8,14,28,0.6)';
ctx.fill();
ctx.strokeStyle = 'rgba(100,150,255,0.12)';
ctx.lineWidth = 1;
ctx.stroke();

ctx.font = `600 ${fontSize}px -apple-system, system-ui, sans-serif`;
ctx.fillStyle = '#f0f4ff';
ctx.font = `300 ${fontSize}px 'Sora', system-ui, sans-serif`;
ctx.fillStyle = 'rgba(200,215,240,0.9)';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText(text, w / 2, h / 2);
Expand Down Expand Up @@ -210,6 +271,16 @@ export function init(container, tip) {
} else {
tipEl.hidden = true;
}
})
.onNodeHover(node => {
if (meHoverCb) {
if (node && node.id === 'me') {
const coords = graph.graph2ScreenCoords(node.x, node.y, node.z);
meHoverCb({ x: coords.x, y: coords.y });
} else {
meHoverCb(null);
}
}
});

graph.d3Force('charge').strength(-220);
Expand All @@ -220,6 +291,12 @@ export function init(container, tip) {
links: []
});

graph.cameraPosition({ x: 0, y: 0, z: 250 });

fontReady.then(() => {
graph.nodeThreeObject(graph.nodeThreeObject());
});

container.addEventListener('mousemove', e => {
mouse.x = e.clientX;
mouse.y = e.clientY;
Expand Down Expand Up @@ -312,6 +389,10 @@ export function removeNode(ref) {
return true;
}

export function onMeHover(cb) {
meHoverCb = cb;
}

export function moveNode(ref, newParentRef) {
const data = graph.graphData();
const node = findNode(ref, data.nodes);
Expand Down
Loading