1 line
73 KiB
JSON
1 line
73 KiB
JSON
{"repo": "cfomodz/fdg-zui", "files": {"index.html": {"0431ee34450c3a3e8d7468b2d76312926b6b8e82": {"t": 1785746579.4297838, "n": 31847, "h": "bf67848ab1a40c2d", "c": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\" />\n<title>FDG ZUI · Spider Planner</title>\n<script src=\"https://unpkg.com/vis-network/standalone/umd/vis-network.min.js\"></script>\n<style>\n :root {\n --bg: #0b0d10;\n --bg-2: #14181d;\n --panel: rgba(20, 24, 30, 0.92);\n --border: #2a3038;\n --text: #e6e9ee;\n --muted: #8a93a0;\n --accent: #00d2ff;\n --accent-2: #7c5cff;\n --motion: #ff6b6b;\n --second: #ffa94d;\n --amend: #ffd43b;\n --debate: #74c0fc;\n --decision: #51cf66;\n --action: #b197fc;\n --note: #adb5bd;\n }\n * { box-sizing: border-box; }\n html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);\n font-family: 'Inter', 'Segoe UI', system-ui, sans-serif; overflow: hidden; }\n #app { display: grid; grid-template-columns: 1fr 420px; height: 100vh; }\n #graph { position: relative; background:\n radial-gradient(ellipse at 30% 20%, rgba(0,210,255,0.06), transparent 60%),\n radial-gradient(ellipse at 70% 80%, rgba(124,92,255,0.06), transparent 60%),\n var(--bg); }\n #mynetwork { width: 100%; height: 100%; }\n\n #toolbar {\n position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; flex-wrap: wrap;\n background: var(--panel); border: 1px solid var(--border); padding: 8px 10px;\n border-radius: 10px; backdrop-filter: blur(10px); z-index: 5; align-items: center;\n }\n #toolbar .group { display: flex; gap: 6px; align-items: center; }\n #toolbar .sep { width: 1px; height: 22px; background: var(--border); margin: 0 4px; }\n #toolbar small { color: var(--muted); }\n\n button, select, input[type=\"text\"], textarea {\n background: #1b2026; color: var(--text); border: 1px solid var(--border);\n border-radius: 6px; padding: 6px 10px; font-size: 12px; font-family: inherit;\n }\n button { cursor: pointer; transition: background 0.15s, border-color 0.15s; }\n button:hover { background: #232a32; border-color: #3a4250; }\n button.primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border: none; color: #fff; }\n button.primary:hover { filter: brightness(1.1); }\n button.danger { color: #ff6b6b; }\n button:disabled { opacity: 0.5; cursor: not-allowed; }\n\n #legend {\n position: absolute; bottom: 12px; left: 12px; display: flex; gap: 10px; flex-wrap: wrap;\n background: var(--panel); border: 1px solid var(--border); padding: 6px 10px;\n border-radius: 10px; font-size: 11px; color: var(--muted); z-index: 5;\n }\n #legend .pill { display: inline-flex; align-items: center; gap: 6px; }\n #legend .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }\n\n /* Side panel */\n #side {\n background: var(--bg-2); border-left: 1px solid var(--border);\n display: flex; flex-direction: column; min-width: 0;\n }\n #side header { padding: 14px 16px; border-bottom: 1px solid var(--border); }\n #side header .crumbs { font-size: 11px; color: var(--muted); margin-bottom: 6px;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n #side header h2 { margin: 0; font-size: 16px; font-weight: 600; }\n #side header .meta { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }\n #side header .meta select, #side header .meta input { flex: 1; min-width: 0; }\n\n #node-body { padding: 12px 16px; border-bottom: 1px solid var(--border); }\n #node-body textarea { width: 100%; min-height: 80px; resize: vertical; }\n #node-actions { padding: 8px 16px; display: flex; gap: 6px; flex-wrap: wrap; border-bottom: 1px solid var(--border); }\n\n #context-controls {\n padding: 10px 16px; border-bottom: 1px solid var(--border);\n display: grid; grid-template-columns: auto 1fr auto; gap: 8px 10px; align-items: center;\n font-size: 12px; color: var(--muted);\n }\n #context-controls input[type=\"number\"] { width: 60px; padding: 4px 6px; }\n #context-controls .row { display: contents; }\n #context-controls .ctx-summary { grid-column: 1 / -1; font-size: 11px; color: var(--muted); }\n\n #chat { flex: 1; display: flex; flex-direction: column; min-height: 0; }\n #chat-log { flex: 1; overflow-y: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 12px; }\n .msg { padding: 10px 12px; border-radius: 8px; font-size: 13px; line-height: 1.5;\n white-space: pre-wrap; word-wrap: break-word; }\n .msg.user { background: #1f2530; align-self: flex-end; max-width: 90%; }\n .msg.assistant { background: #161b22; border-left: 3px solid var(--accent); max-width: 95%; }\n .msg .reason { background: rgba(124,92,255,0.06); border-left: 3px solid var(--accent-2);\n margin: 6px 0 8px 0; padding: 8px 10px; font-size: 12px; color: var(--muted);\n border-radius: 4px; max-height: 200px; overflow-y: auto; }\n .msg .reason summary { cursor: pointer; color: var(--accent-2); font-weight: 500; outline: none; }\n .msg .role { font-size: 10px; text-transform: uppercase; letter-spacing: 1px;\n color: var(--muted); margin-bottom: 4px; }\n\n #chat-input { border-top: 1px solid var(--border); padding: 10px 12px;\n display: flex; gap: 8px; align-items: flex-end; }\n #chat-input textarea { flex: 1; min-height: 44px; max-height: 200px; resize: none; }\n\n #empty { padding: 40px 20px; text-align: center; color: var(--muted); font-size: 13px; }\n\n /* Settings modal */\n #settings-overlay {\n position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100;\n display: none; align-items: center; justify-content: center;\n }\n #settings-overlay.visible { display: flex; }\n #settings-modal {\n background: var(--bg-2); border: 1px solid var(--border); border-radius: 12px;\n padding: 20px; width: 460px; max-width: 90vw;\n }\n #settings-modal h3 { margin: 0 0 12px 0; font-size: 15px; }\n #settings-modal label { display: block; font-size: 11px; color: var(--muted);\n margin: 10px 0 4px; text-transform: uppercase; letter-spacing: 1px; }\n #settings-modal input, #settings-modal select, #settings-modal textarea {\n width: 100%; font-size: 13px; padding: 8px 10px;\n }\n #settings-modal textarea { min-height: 80px; resize: vertical; }\n #settings-modal .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }\n\n /* Scrollbars */\n ::-webkit-scrollbar { width: 8px; height: 8px; }\n ::-webkit-scrollbar-thumb { background: #2a3038; border-radius: 4px; }\n ::-webkit-scrollbar-track { background: transparent; }\n</style>\n</head>\n<body>\n\n<div id=\"app\">\n <div id=\"graph\">\n <div id=\"toolbar\">\n <div class=\"group\">\n <button id=\"btn-add-root\" title=\"Add a top-level motion\">+ Root</button>\n <button id=\"btn-add-child\" disabled title=\"Add child of selected (Tab)\">+ Child</button>\n <button id=\"btn-add-sibling\" disabled title=\"Add sibling of selected (Enter)\">+ Sibling</button>\n </div>\n <div class=\"sep\"></div>\n <div class=\"group\">\n <button id=\"btn-delete\" class=\"danger\" disabled title=\"Delete subtree (Del)\">Delete</button>\n <button id=\"btn-promote\" disabled title=\"Promote (Shift+Tab)\">↰ Promote</button>\n </div>\n <div class=\"sep\"></div>\n <div class=\"group\">\n <button id=\"btn-fit\">Fit</button>\n <button id=\"btn-physics\">Physics: On</button>\n </div>\n <div class=\"sep\"></div>\n <div class=\"group\">\n <button id=\"btn-export\">Export</button>\n <button id=\"btn-import\">Import</button>\n <input type=\"file\" id=\"file-input\" accept=\"application/json\" style=\"display:none\" />\n </div>\n <div class=\"sep\"></div>\n <div class=\"group\">\n <button id=\"btn-settings\">⚙ API</button>\n </div>\n </div>\n\n <div id=\"legend\">\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--motion)\"></span>Motion</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--second)\"></span>Second</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--amend)\"></span>Amendment</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--debate)\"></span>Debate</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--decision)\"></span>Decision</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--action)\"></span>Action</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--note)\"></span>Note</span>\n </div>\n\n <div id=\"mynetwork\"></div>\n </div>\n\n <aside id=\"side\">\n <div id=\"empty\">\n <p>Select a node to focus it.</p>\n <p style=\"font-size:11px\">Tab = child · Enter = sibling · Del = delete · Shift+Tab = promote</p>\n </div>\n\n <header style=\"display:none\">\n <div class=\"crumbs\" id=\"crumbs\"></div>\n <h2 id=\"node-title\" contenteditable=\"true\" spellcheck=\"false\">Untitled</h2>\n <div class=\"meta\">\n <select id=\"node-kind\">\n <option value=\"motion\">Motion</option>\n <option value=\"second\">Second</option>\n <option value=\"amendment\">Amendment</option>\n <option value=\"debate\">Debate</option>\n <option value=\"decision\">Decision</option>\n <option value=\"action\">Action</option>\n <option value=\"note\">Note</option>\n </select>\n </div>\n </header>\n\n <div id=\"node-body\" style=\"display:none\">\n <textarea id=\"node-notes\" placeholder=\"Notes, rationale, supporting detail for this level…\"></textarea>\n </div>\n\n <div id=\"context-controls\" style=\"display:none\">\n <div class=\"row\">\n <span>Levels up</span>\n <input type=\"range\" id=\"ctx-up\" min=\"0\" max=\"20\" value=\"2\" />\n <input type=\"number\" id=\"ctx-up-num\" min=\"0\" max=\"999\" value=\"2\" />\n </div>\n <div class=\"row\">\n <span>Levels down</span>\n <input type=\"range\" id=\"ctx-down\" min=\"0\" max=\"20\" value=\"2\" />\n <input type=\"number\" id=\"ctx-down-num\" min=\"0\" max=\"999\" value=\"2\" />\n </div>\n <div class=\"row\">\n <span>Siblings</span>\n <input type=\"range\" id=\"ctx-sib\" min=\"0\" max=\"20\" value=\"0\" />\n <input type=\"number\" id=\"ctx-sib-num\" min=\"0\" max=\"999\" value=\"0\" />\n </div>\n <div class=\"ctx-summary\" id=\"ctx-summary\">—</div>\n </div>\n\n <div id=\"chat\" style=\"display:none\">\n <div id=\"chat-log\"></div>\n <div id=\"chat-input\">\n <textarea id=\"chat-text\" placeholder=\"Ask, plan, debate this level… (Ctrl+Enter to send)\"></textarea>\n <button id=\"chat-send\" class=\"primary\">Send</button>\n </div>\n </div>\n </aside>\n</div>\n\n<div id=\"settings-overlay\">\n <div id=\"settings-modal\">\n <h3>Reasoning model API</h3>\n <p style=\"font-size:12px;color:var(--muted);margin:0 0 8px\">\n OpenAI-compatible endpoint. Works with DeepSeek (deepseek-reasoner), OpenAI (o1/o3/gpt-5), Anthropic-compatible proxies, local Ollama, etc.\n </p>\n <label>Endpoint URL</label>\n <input type=\"text\" id=\"cfg-endpoint\" placeholder=\"https://api.deepseek.com/v1/chat/completions\" />\n <label>Model</label>\n <input type=\"text\" id=\"cfg-model\" placeholder=\"deepseek-reasoner\" />\n <label>API Key</label>\n <input type=\"text\" id=\"cfg-key\" placeholder=\"sk-…\" />\n <label>System prompt</label>\n <textarea id=\"cfg-system\">You are a parliamentary-procedure-aware planning assistant. The user is editing a hierarchical project outline. Reason carefully and step-by-step. Reference parent context, current node, and child sub-points. Suggest motions, amendments, decisions, and concrete action items. Be precise and structured.</textarea>\n <label><input type=\"checkbox\" id=\"cfg-stream\" checked style=\"width:auto;margin-right:6px\" />Stream responses</label>\n <div class=\"actions\">\n <button id=\"cfg-cancel\">Cancel</button>\n <button id=\"cfg-save\" class=\"primary\">Save</button>\n </div>\n </div>\n</div>\n\n<script>\n'use strict';\n\n// ---------- State ----------\nconst STORE_KEY = 'fdg-zui-state-v1';\nconst CFG_KEY = 'fdg-zui-config-v1';\n\nconst KIND_COLORS = {\n motion: '#ff6b6b',\n second: '#ffa94d',\n amendment: '#ffd43b',\n debate: '#74c0fc',\n decision: '#51cf66',\n action: '#b197fc',\n note: '#adb5bd',\n};\nconst KIND_SHAPES = {\n motion: 'hexagon', second: 'dot', amendment: 'diamond',\n debate: 'dot', decision: 'star', action: 'square', note: 'dot',\n};\n\nlet state = loadState() || {\n nodes: {}, // id -> { id, parent, title, notes, kind, chat: [{role,content,reasoning?}], ctx: {up,down,sib} }\n rootOrder: [],\n};\nlet config = loadConfig() || {\n endpoint: 'https://api.deepseek.com/v1/chat/completions',\n model: 'deepseek-reasoner',\n key: '',\n system: 'You are a parliamentary-procedure-aware planning assistant. The user is editing a hierarchical project outline. Reason carefully and step-by-step. Reference parent context, current node, and child sub-points. Suggest motions, amendments, decisions, and concrete action items. Be precise and structured.',\n stream: true,\n};\n\nlet selectedId = null;\nlet physicsOn = true;\n\n// ---------- Persistence ----------\nfunction saveState() { localStorage.setItem(STORE_KEY, JSON.stringify(state)); }\nfunction loadState() { try { return JSON.parse(localStorage.getItem(STORE_KEY)); } catch { return null; } }\nfunction saveConfig() { localStorage.setItem(CFG_KEY, JSON.stringify(config)); }\nfunction loadConfig() { try { return JSON.parse(localStorage.getItem(CFG_KEY)); } catch { return null; } }\n\n// ---------- Tree helpers ----------\nfunction uid() { return 'n_' + Math.random().toString(36).slice(2, 10); }\n\nfunction childrenOf(id) {\n return Object.values(state.nodes)\n .filter(n => n.parent === id)\n .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));\n}\nfunction ancestorsOf(id) {\n const out = [];\n let cur = state.nodes[id];\n while (cur && cur.parent) { cur = state.nodes[cur.parent]; if (cur) out.push(cur); }\n return out;\n}\nfunction descendantsOf(id, depth) {\n const out = [];\n function walk(pid, d) {\n if (d <= 0) return;\n for (const c of childrenOf(pid)) { out.push({ node: c, depth: depth - d + 1 }); walk(c.id, d - 1); }\n }\n walk(id, depth);\n return out;\n}\nfunction siblingsOf(id) {\n const n = state.nodes[id]; if (!n) return [];\n return Object.values(state.nodes).filter(x => x.parent === n.parent && x.id !== id);\n}\nfunction depthOf(id) { return ancestorsOf(id).length; }\nfunction pathOf(id) {\n const a = ancestorsOf(id).reverse();\n const n = state.nodes[id];\n return [...a.map(x => x.title), n ? n.title : ''].join(' › ');\n}\n\nfunction addNode({ parent = null, title = 'New item', kind = null } = {}) {\n const id = uid();\n const parentNode = parent ? state.nodes[parent] : null;\n const inferredKind = kind || (parentNode ? defaultChildKind(parentNode.kind) : 'motion');\n const order = parent ? childrenOf(parent).length : state.rootOrder.length;\n state.nodes[id] = {\n id, parent, title, notes: '', kind: inferredKind,\n chat: [], ctx: { up: 2, down: 2, sib: 0 }, order,\n };\n if (!parent) state.rootOrder.push(id);\n saveState();\n return id;\n}\nfunction defaultChildKind(parentKind) {\n if (parentKind === 'motion') return 'amendment';\n if (parentKind === 'amendment') return 'debate';\n if (parentKind === 'debate') return 'decision';\n if (parentKind === 'decision') return 'action';\n return 'note';\n}\nfunction deleteNode(id) {\n const kids = childrenOf(id);\n for (const c of kids) deleteNode(c.id);\n const n = state.nodes[id];\n if (n && !n.parent) state.rootOrder = state.rootOrder.filter(x => x !== id);\n delete state.nodes[id];\n saveState();\n}\nfunction promoteNode(id) {\n const n = state.nodes[id]; if (!n || !n.parent) return;\n const grand = state.nodes[n.parent]?.parent ?? null;\n n.parent = grand;\n if (!grand) state.rootOrder.push(id);\n saveState();\n}\n\n// ---------- Graph rendering ----------\nlet network = null;\nlet nodesDS = new vis.DataSet([]);\nlet edgesDS = new vis.DataSet([]);\n\nfunction rebuildGraph() {\n const visNodes = [];\n const visEdges = [];\n for (const n of Object.values(state.nodes)) {\n const depth = depthOf(n.id);\n const size = Math.max(10, 28 - depth * 2);\n visNodes.push({\n id: n.id,\n label: truncate(n.title, 28),\n shape: KIND_SHAPES[n.kind] || 'dot',\n size,\n color: { background: KIND_COLORS[n.kind] || '#888', border: '#ffffff22' },\n borderWidth: selectedId === n.id ? 3 : 1,\n font: { color: '#e6e9ee', size: Math.max(10, 14 - depth) },\n });\n if (n.parent) {\n visEdges.push({\n id: `${n.parent}->${n.id}`,\n from: n.parent, to: n.id,\n color: { color: '#3a4250', opacity: 0.6 },\n smooth: { type: 'continuous' },\n });\n }\n }\n nodesDS.clear(); nodesDS.add(visNodes);\n edgesDS.clear(); edgesDS.add(visEdges);\n}\n\nfunction initNetwork() {\n const container = document.getElementById('mynetwork');\n network = new vis.Network(container, { nodes: nodesDS, edges: edgesDS }, {\n nodes: { font: { color: '#e6e9ee', face: 'Inter, sans-serif' } },\n edges: { color: '#3a4250', smooth: { type: 'continuous' } },\n physics: {\n enabled: true,\n barnesHut: { gravitationalConstant: -3000, springLength: 110, springConstant: 0.04, damping: 0.3 },\n stabilization: { iterations: 80 },\n },\n interaction: { hover: true, multiselect: false, navigationButtons: false },\n });\n network.on('click', (params) => {\n if (params.nodes.length) selectNode(params.nodes[0]);\n else { selectedId = null; renderSide(); rebuildGraph(); }\n });\n network.on('doubleClick', (params) => {\n if (params.nodes.length) {\n const id = params.nodes[0];\n const newTitle = prompt('Rename:', state.nodes[id].title);\n if (newTitle != null) { state.nodes[id].title = newTitle; saveState(); rebuildGraph(); renderSide(); }\n }\n });\n}\n\nfunction truncate(s, n) { return (s || '').length > n ? s.slice(0, n - 1) + '…' : (s || ''); }\n\n// ---------- Side panel ----------\nfunction selectNode(id) { selectedId = id; renderSide(); rebuildGraph(); updateToolbarButtons(); }\n\nfunction renderSide() {\n const empty = document.getElementById('empty');\n const header = document.querySelector('#side header');\n const body = document.getElementById('node-body');\n const ctx = document.getElementById('context-controls');\n const chat = document.getElementById('chat');\n\n if (!selectedId || !state.nodes[selectedId]) {\n empty.style.display = 'block';\n header.style.display = 'none';\n body.style.display = 'none';\n ctx.style.display = 'none';\n chat.style.display = 'none';\n return;\n }\n empty.style.display = 'none';\n header.style.display = 'block';\n body.style.display = 'block';\n ctx.style.display = 'grid';\n chat.style.display = 'flex';\n\n const n = state.nodes[selectedId];\n document.getElementById('crumbs').textContent = pathOf(n.id) || '(root)';\n document.getElementById('node-title').textContent = n.title;\n document.getElementById('node-kind').value = n.kind;\n document.getElementById('node-notes').value = n.notes || '';\n\n const setCtx = (key, idR, idN) => {\n document.getElementById(idR).value = n.ctx[key];\n document.getElementById(idN).value = n.ctx[key];\n };\n setCtx('up', 'ctx-up', 'ctx-up-num');\n setCtx('down', 'ctx-down', 'ctx-down-num');\n setCtx('sib', 'ctx-sib', 'ctx-sib-num');\n updateCtxSummary();\n\n renderChat();\n}\n\nfunction updateCtxSummary() {\n const n = state.nodes[selectedId]; if (!n) return;\n const up = ancestorsOf(n.id).slice(0, n.ctx.up).length;\n const down = descendantsOf(n.id, n.ctx.down).length;\n const sib = Math.min(siblingsOf(n.id).length, n.ctx.sib);\n document.getElementById('ctx-summary').textContent =\n `Context: ${up} ancestor${up===1?'':'s'} · ${down} descendant${down===1?'':'s'} · ${sib} sibling${sib===1?'':'s'}`;\n}\n\nfunction renderChat() {\n const log = document.getElementById('chat-log');\n log.innerHTML = '';\n const n = state.nodes[selectedId]; if (!n) return;\n for (const m of n.chat) {\n const div = document.createElement('div');\n div.className = 'msg ' + m.role;\n const role = document.createElement('div');\n role.className = 'role'; role.textContent = m.role;\n div.appendChild(role);\n if (m.reasoning) {\n const det = document.createElement('details');\n det.className = 'reason';\n const sum = document.createElement('summary');\n sum.textContent = '🧠 reasoning';\n det.appendChild(sum);\n const pre = document.createElement('div');\n pre.textContent = m.reasoning;\n det.appendChild(pre);\n div.appendChild(det);\n }\n const content = document.createElement('div');\n content.textContent = m.content;\n div.appendChild(content);\n log.appendChild(div);\n }\n log.scrollTop = log.scrollHeight;\n}\n\n// ---------- Context assembly ----------\nfunction buildContextString(nodeId) {\n const n = state.nodes[nodeId]; if (!n) return '';\n const lines = [];\n const ancestors = ancestorsOf(n.id).slice(0, n.ctx.up).reverse();\n if (ancestors.length) {\n lines.push('## Ancestor chain (root → parent)');\n ancestors.forEach((a, i) => {\n lines.push(`${' '.repeat(i)}- [${a.kind}] ${a.title}${a.notes ? ` — ${a.notes}` : ''}`);\n });\n }\n lines.push('');\n lines.push(`## Current node (depth ${depthOf(n.id)})`);\n lines.push(`- [${n.kind}] **${n.title}**`);\n if (n.notes) lines.push(` Notes: ${n.notes}`);\n\n if (n.ctx.sib > 0) {\n const sibs = siblingsOf(n.id).slice(0, n.ctx.sib);\n if (sibs.length) {\n lines.push('');\n lines.push('## Siblings');\n sibs.forEach(s => lines.push(`- [${s.kind}] ${s.title}${s.notes ? ` — ${s.notes}` : ''}`));\n }\n }\n\n if (n.ctx.down > 0) {\n const desc = descendantsOf(n.id, n.ctx.down);\n if (desc.length) {\n lines.push('');\n lines.push(`## Descendants (down to ${n.ctx.down} level${n.ctx.down===1?'':'s'})`);\n for (const { node, depth } of desc) {\n lines.push(`${' '.repeat(depth)}- [${node.kind}] ${node.title}${node.notes ? ` — ${node.notes}` : ''}`);\n }\n }\n }\n return lines.join('\\n');\n}\n\n// ---------- Chat ----------\nasync function sendChat() {\n const n = state.nodes[selectedId]; if (!n) return;\n const txt = document.getElementById('chat-text');\n const msg = txt.value.trim(); if (!msg) return;\n txt.value = '';\n\n if (!config.key || !config.endpoint) {\n alert('Configure your API endpoint and key first (⚙ API).');\n openSettings(); return;\n }\n\n n.chat.push({ role: 'user', content: msg });\n renderChat(); saveState();\n\n const ctxText = buildContextString(n.id);\n const systemMsg = `${config.system}\\n\\n# Project outline context\\n${ctxText}`;\n\n // Build message thread from this node's history\n const messages = [{ role: 'system', content: systemMsg }];\n for (const m of n.chat) messages.push({ role: m.role, content: m.content });\n\n const placeholder = { role: 'assistant', content: '', reasoning: '' };\n n.chat.push(placeholder);\n renderChat();\n\n try {\n if (config.stream) {\n await streamCompletion(messages, (deltaContent, deltaReasoning) => {\n if (deltaContent) placeholder.content += deltaContent;\n if (deltaReasoning) placeholder.reasoning += deltaReasoning;\n renderChat();\n });\n } else {\n const res = await fetch(config.endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.key}` },\n body: JSON.stringify({ model: config.model, messages, stream: false }),\n });\n const json = await res.json();\n const choice = json.choices?.[0]?.message || {};\n placeholder.content = choice.content || '';\n placeholder.reasoning = choice.reasoning_content || choice.reasoning || '';\n }\n } catch (e) {\n placeholder.content = `⚠️ Error: ${e.message}`;\n }\n if (!placeholder.reasoning) delete placeholder.reasoning;\n saveState(); renderChat();\n}\n\nasync function streamCompletion(messages, onDelta) {\n const res = await fetch(config.endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.key}` },\n body: JSON.stringify({ model: config.model, messages, stream: true }),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => '');\n throw new Error(`${res.status} ${res.statusText} ${text.slice(0, 200)}`);\n }\n const reader = res.body.getReader();\n const dec = new TextDecoder();\n let buf = '';\n while (true) {\n const { value, done } = await reader.read();\n if (done) break;\n buf += dec.decode(value, { stream: true });\n let idx;\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx).trim();\n buf = buf.slice(idx + 1);\n if (!line.startsWith('data:')) continue;\n const payload = line.slice(5).trim();\n if (payload === '[DONE]') return;\n try {\n const j = JSON.parse(payload);\n const d = j.choices?.[0]?.delta || {};\n onDelta(d.content || '', d.reasoning_content || d.reasoning || '');\n } catch { /* ignore */ }\n }\n }\n}\n\n// ---------- Wiring ----------\nfunction updateToolbarButtons() {\n const has = !!selectedId;\n document.getElementById('btn-add-child').disabled = !has;\n document.getElementById('btn-add-sibling').disabled = !has;\n document.getElementById('btn-delete').disabled = !has;\n document.getElementById('btn-promote').disabled = !has || !state.nodes[selectedId]?.parent;\n}\n\ndocument.getElementById('btn-add-root').onclick = () => {\n const id = addNode({ parent: null, title: 'New motion', kind: 'motion' });\n rebuildGraph(); selectNode(id);\n};\ndocument.getElementById('btn-add-child').onclick = () => {\n if (!selectedId) return;\n const id = addNode({ parent: selectedId, title: 'New child' });\n rebuildGraph(); selectNode(id);\n};\ndocument.getElementById('btn-add-sibling').onclick = () => {\n const n = state.nodes[selectedId]; if (!n) return;\n const id = addNode({ parent: n.parent, title: 'New sibling', kind: n.kind });\n rebuildGraph(); selectNode(id);\n};\ndocument.getElementById('btn-delete').onclick = () => {\n if (!selectedId) return;\n if (!confirm('Delete this node and all its children?')) return;\n deleteNode(selectedId); selectedId = null; rebuildGraph(); renderSide(); updateToolbarButtons();\n};\ndocument.getElementById('btn-promote').onclick = () => {\n if (!selectedId) return;\n promoteNode(selectedId); rebuildGraph(); renderSide();\n};\ndocument.getElementById('btn-fit').onclick = () => network && network.fit({ animation: true });\ndocument.getElementById('btn-physics').onclick = (e) => {\n physicsOn = !physicsOn;\n network.setOptions({ physics: { enabled: physicsOn } });\n e.target.textContent = `Physics: ${physicsOn ? 'On' : 'Off'}`;\n};\ndocument.getElementById('btn-export').onclick = () => {\n const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });\n const a = document.createElement('a');\n a.href = URL.createObjectURL(blob);\n a.download = `fdg-zui-${new Date().toISOString().slice(0,10)}.json`;\n a.click();\n};\ndocument.getElementById('btn-import').onclick = () => document.getElementById('file-input').click();\ndocument.getElementById('file-input').onchange = (e) => {\n const f = e.target.files[0]; if (!f) return;\n const r = new FileReader();\n r.onload = () => {\n try {\n const j = JSON.parse(r.result);\n if (!j.nodes) throw new Error('invalid file');\n state = j; saveState(); selectedId = null; rebuildGraph(); renderSide();\n } catch (err) { alert('Import failed: ' + err.message); }\n };\n r.readAsText(f);\n};\n\n// Title / kind / notes editing\ndocument.getElementById('node-title').addEventListener('blur', (e) => {\n if (!selectedId) return;\n state.nodes[selectedId].title = e.target.textContent.trim() || 'Untitled';\n saveState(); rebuildGraph();\n});\ndocument.getElementById('node-title').addEventListener('keydown', (e) => {\n if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); }\n});\ndocument.getElementById('node-kind').addEventListener('change', (e) => {\n if (!selectedId) return;\n state.nodes[selectedId].kind = e.target.value;\n saveState(); rebuildGraph();\n});\ndocument.getElementById('node-notes').addEventListener('input', (e) => {\n if (!selectedId) return;\n state.nodes[selectedId].notes = e.target.value;\n saveState();\n});\n\n// Context sliders\nfunction bindCtxPair(rangeId, numId, key) {\n const r = document.getElementById(rangeId);\n const n = document.getElementById(numId);\n const sync = (val) => {\n if (!selectedId) return;\n const v = Math.max(0, parseInt(val, 10) || 0);\n state.nodes[selectedId].ctx[key] = v;\n r.value = Math.min(v, parseInt(r.max));\n n.value = v;\n saveState(); updateCtxSummary();\n };\n r.addEventListener('input', () => sync(r.value));\n n.addEventListener('input', () => sync(n.value));\n}\nbindCtxPair('ctx-up', 'ctx-up-num', 'up');\nbindCtxPair('ctx-down', 'ctx-down-num', 'down');\nbindCtxPair('ctx-sib', 'ctx-sib-num', 'sib');\n\n// Chat send\ndocument.getElementById('chat-send').onclick = sendChat;\ndocument.getElementById('chat-text').addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendChat(); }\n});\n\n// Settings\nfunction openSettings() {\n document.getElementById('cfg-endpoint').value = config.endpoint;\n document.getElementById('cfg-model').value = config.model;\n document.getElementById('cfg-key').value = config.key;\n document.getElementById('cfg-system').value = config.system;\n document.getElementById('cfg-stream').checked = !!config.stream;\n document.getElementById('settings-overlay').classList.add('visible');\n}\nfunction closeSettings() { document.getElementById('settings-overlay').classList.remove('visible'); }\ndocument.getElementById('btn-settings').onclick = openSettings;\ndocument.getElementById('cfg-cancel').onclick = closeSettings;\ndocument.getElementById('cfg-save').onclick = () => {\n config = {\n endpoint: document.getElementById('cfg-endpoint').value.trim(),\n model: document.getElementById('cfg-model').value.trim(),\n key: document.getElementById('cfg-key').value.trim(),\n system: document.getElementById('cfg-system').value,\n stream: document.getElementById('cfg-stream').checked,\n };\n saveConfig(); closeSettings();\n};\n\n// Global keyboard shortcuts\ndocument.addEventListener('keydown', (e) => {\n const t = e.target;\n const editing = t.matches('input, textarea, [contenteditable=\"true\"]');\n if (editing) return;\n if (!selectedId && !['n', 'N'].includes(e.key)) return;\n if (e.key === 'Tab' && !e.shiftKey) {\n e.preventDefault();\n const id = addNode({ parent: selectedId, title: 'New child' });\n rebuildGraph(); selectNode(id);\n } else if (e.key === 'Tab' && e.shiftKey) {\n e.preventDefault(); promoteNode(selectedId); rebuildGraph(); renderSide();\n } else if (e.key === 'Enter') {\n e.preventDefault();\n const n = state.nodes[selectedId];\n const id = addNode({ parent: n?.parent ?? null, title: 'New sibling', kind: n?.kind });\n rebuildGraph(); selectNode(id);\n } else if (e.key === 'Delete' || e.key === 'Backspace') {\n if (!confirm('Delete this node and all its children?')) return;\n deleteNode(selectedId); selectedId = null; rebuildGraph(); renderSide(); updateToolbarButtons();\n }\n});\n\n// ---------- Bootstrap ----------\nfunction seedIfEmpty() {\n if (Object.keys(state.nodes).length) return;\n const root = addNode({ parent: null, title: 'Project: Launch new product', kind: 'motion' });\n const a = addNode({ parent: root, title: 'Define MVP scope', kind: 'amendment' });\n addNode({ parent: a, title: 'Discuss must-haves vs nice-to-haves', kind: 'debate' });\n addNode({ parent: a, title: 'Lock scope by Friday', kind: 'decision' });\n const b = addNode({ parent: root, title: 'Resource allocation', kind: 'amendment' });\n addNode({ parent: b, title: 'Hire 2 engineers', kind: 'action' });\n addNode({ parent: root, title: 'Risk: timeline slippage', kind: 'note' });\n}\n\ninitNetwork();\nseedIfEmpty();\nrebuildGraph();\nrenderSide();\nupdateToolbarButtons();\nnetwork.once('stabilizationIterationsDone', () => network.fit());\n</script>\n</body>\n</html>\n"}, "8ddfd88058f12abe25ecc5b61f2e7252f5ba8c6e": {"t": 1785765933.508486, "n": 31847, "h": "bf67848ab1a40c2d", "c": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\" />\n<title>FDG ZUI · Spider Planner</title>\n<script src=\"https://unpkg.com/vis-network/standalone/umd/vis-network.min.js\"></script>\n<style>\n :root {\n --bg: #0b0d10;\n --bg-2: #14181d;\n --panel: rgba(20, 24, 30, 0.92);\n --border: #2a3038;\n --text: #e6e9ee;\n --muted: #8a93a0;\n --accent: #00d2ff;\n --accent-2: #7c5cff;\n --motion: #ff6b6b;\n --second: #ffa94d;\n --amend: #ffd43b;\n --debate: #74c0fc;\n --decision: #51cf66;\n --action: #b197fc;\n --note: #adb5bd;\n }\n * { box-sizing: border-box; }\n html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);\n font-family: 'Inter', 'Segoe UI', system-ui, sans-serif; overflow: hidden; }\n #app { display: grid; grid-template-columns: 1fr 420px; height: 100vh; }\n #graph { position: relative; background:\n radial-gradient(ellipse at 30% 20%, rgba(0,210,255,0.06), transparent 60%),\n radial-gradient(ellipse at 70% 80%, rgba(124,92,255,0.06), transparent 60%),\n var(--bg); }\n #mynetwork { width: 100%; height: 100%; }\n\n #toolbar {\n position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; flex-wrap: wrap;\n background: var(--panel); border: 1px solid var(--border); padding: 8px 10px;\n border-radius: 10px; backdrop-filter: blur(10px); z-index: 5; align-items: center;\n }\n #toolbar .group { display: flex; gap: 6px; align-items: center; }\n #toolbar .sep { width: 1px; height: 22px; background: var(--border); margin: 0 4px; }\n #toolbar small { color: var(--muted); }\n\n button, select, input[type=\"text\"], textarea {\n background: #1b2026; color: var(--text); border: 1px solid var(--border);\n border-radius: 6px; padding: 6px 10px; font-size: 12px; font-family: inherit;\n }\n button { cursor: pointer; transition: background 0.15s, border-color 0.15s; }\n button:hover { background: #232a32; border-color: #3a4250; }\n button.primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border: none; color: #fff; }\n button.primary:hover { filter: brightness(1.1); }\n button.danger { color: #ff6b6b; }\n button:disabled { opacity: 0.5; cursor: not-allowed; }\n\n #legend {\n position: absolute; bottom: 12px; left: 12px; display: flex; gap: 10px; flex-wrap: wrap;\n background: var(--panel); border: 1px solid var(--border); padding: 6px 10px;\n border-radius: 10px; font-size: 11px; color: var(--muted); z-index: 5;\n }\n #legend .pill { display: inline-flex; align-items: center; gap: 6px; }\n #legend .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }\n\n /* Side panel */\n #side {\n background: var(--bg-2); border-left: 1px solid var(--border);\n display: flex; flex-direction: column; min-width: 0;\n }\n #side header { padding: 14px 16px; border-bottom: 1px solid var(--border); }\n #side header .crumbs { font-size: 11px; color: var(--muted); margin-bottom: 6px;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n #side header h2 { margin: 0; font-size: 16px; font-weight: 600; }\n #side header .meta { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }\n #side header .meta select, #side header .meta input { flex: 1; min-width: 0; }\n\n #node-body { padding: 12px 16px; border-bottom: 1px solid var(--border); }\n #node-body textarea { width: 100%; min-height: 80px; resize: vertical; }\n #node-actions { padding: 8px 16px; display: flex; gap: 6px; flex-wrap: wrap; border-bottom: 1px solid var(--border); }\n\n #context-controls {\n padding: 10px 16px; border-bottom: 1px solid var(--border);\n display: grid; grid-template-columns: auto 1fr auto; gap: 8px 10px; align-items: center;\n font-size: 12px; color: var(--muted);\n }\n #context-controls input[type=\"number\"] { width: 60px; padding: 4px 6px; }\n #context-controls .row { display: contents; }\n #context-controls .ctx-summary { grid-column: 1 / -1; font-size: 11px; color: var(--muted); }\n\n #chat { flex: 1; display: flex; flex-direction: column; min-height: 0; }\n #chat-log { flex: 1; overflow-y: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 12px; }\n .msg { padding: 10px 12px; border-radius: 8px; font-size: 13px; line-height: 1.5;\n white-space: pre-wrap; word-wrap: break-word; }\n .msg.user { background: #1f2530; align-self: flex-end; max-width: 90%; }\n .msg.assistant { background: #161b22; border-left: 3px solid var(--accent); max-width: 95%; }\n .msg .reason { background: rgba(124,92,255,0.06); border-left: 3px solid var(--accent-2);\n margin: 6px 0 8px 0; padding: 8px 10px; font-size: 12px; color: var(--muted);\n border-radius: 4px; max-height: 200px; overflow-y: auto; }\n .msg .reason summary { cursor: pointer; color: var(--accent-2); font-weight: 500; outline: none; }\n .msg .role { font-size: 10px; text-transform: uppercase; letter-spacing: 1px;\n color: var(--muted); margin-bottom: 4px; }\n\n #chat-input { border-top: 1px solid var(--border); padding: 10px 12px;\n display: flex; gap: 8px; align-items: flex-end; }\n #chat-input textarea { flex: 1; min-height: 44px; max-height: 200px; resize: none; }\n\n #empty { padding: 40px 20px; text-align: center; color: var(--muted); font-size: 13px; }\n\n /* Settings modal */\n #settings-overlay {\n position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100;\n display: none; align-items: center; justify-content: center;\n }\n #settings-overlay.visible { display: flex; }\n #settings-modal {\n background: var(--bg-2); border: 1px solid var(--border); border-radius: 12px;\n padding: 20px; width: 460px; max-width: 90vw;\n }\n #settings-modal h3 { margin: 0 0 12px 0; font-size: 15px; }\n #settings-modal label { display: block; font-size: 11px; color: var(--muted);\n margin: 10px 0 4px; text-transform: uppercase; letter-spacing: 1px; }\n #settings-modal input, #settings-modal select, #settings-modal textarea {\n width: 100%; font-size: 13px; padding: 8px 10px;\n }\n #settings-modal textarea { min-height: 80px; resize: vertical; }\n #settings-modal .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }\n\n /* Scrollbars */\n ::-webkit-scrollbar { width: 8px; height: 8px; }\n ::-webkit-scrollbar-thumb { background: #2a3038; border-radius: 4px; }\n ::-webkit-scrollbar-track { background: transparent; }\n</style>\n</head>\n<body>\n\n<div id=\"app\">\n <div id=\"graph\">\n <div id=\"toolbar\">\n <div class=\"group\">\n <button id=\"btn-add-root\" title=\"Add a top-level motion\">+ Root</button>\n <button id=\"btn-add-child\" disabled title=\"Add child of selected (Tab)\">+ Child</button>\n <button id=\"btn-add-sibling\" disabled title=\"Add sibling of selected (Enter)\">+ Sibling</button>\n </div>\n <div class=\"sep\"></div>\n <div class=\"group\">\n <button id=\"btn-delete\" class=\"danger\" disabled title=\"Delete subtree (Del)\">Delete</button>\n <button id=\"btn-promote\" disabled title=\"Promote (Shift+Tab)\">↰ Promote</button>\n </div>\n <div class=\"sep\"></div>\n <div class=\"group\">\n <button id=\"btn-fit\">Fit</button>\n <button id=\"btn-physics\">Physics: On</button>\n </div>\n <div class=\"sep\"></div>\n <div class=\"group\">\n <button id=\"btn-export\">Export</button>\n <button id=\"btn-import\">Import</button>\n <input type=\"file\" id=\"file-input\" accept=\"application/json\" style=\"display:none\" />\n </div>\n <div class=\"sep\"></div>\n <div class=\"group\">\n <button id=\"btn-settings\">⚙ API</button>\n </div>\n </div>\n\n <div id=\"legend\">\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--motion)\"></span>Motion</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--second)\"></span>Second</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--amend)\"></span>Amendment</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--debate)\"></span>Debate</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--decision)\"></span>Decision</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--action)\"></span>Action</span>\n <span class=\"pill\"><span class=\"dot\" style=\"background:var(--note)\"></span>Note</span>\n </div>\n\n <div id=\"mynetwork\"></div>\n </div>\n\n <aside id=\"side\">\n <div id=\"empty\">\n <p>Select a node to focus it.</p>\n <p style=\"font-size:11px\">Tab = child · Enter = sibling · Del = delete · Shift+Tab = promote</p>\n </div>\n\n <header style=\"display:none\">\n <div class=\"crumbs\" id=\"crumbs\"></div>\n <h2 id=\"node-title\" contenteditable=\"true\" spellcheck=\"false\">Untitled</h2>\n <div class=\"meta\">\n <select id=\"node-kind\">\n <option value=\"motion\">Motion</option>\n <option value=\"second\">Second</option>\n <option value=\"amendment\">Amendment</option>\n <option value=\"debate\">Debate</option>\n <option value=\"decision\">Decision</option>\n <option value=\"action\">Action</option>\n <option value=\"note\">Note</option>\n </select>\n </div>\n </header>\n\n <div id=\"node-body\" style=\"display:none\">\n <textarea id=\"node-notes\" placeholder=\"Notes, rationale, supporting detail for this level…\"></textarea>\n </div>\n\n <div id=\"context-controls\" style=\"display:none\">\n <div class=\"row\">\n <span>Levels up</span>\n <input type=\"range\" id=\"ctx-up\" min=\"0\" max=\"20\" value=\"2\" />\n <input type=\"number\" id=\"ctx-up-num\" min=\"0\" max=\"999\" value=\"2\" />\n </div>\n <div class=\"row\">\n <span>Levels down</span>\n <input type=\"range\" id=\"ctx-down\" min=\"0\" max=\"20\" value=\"2\" />\n <input type=\"number\" id=\"ctx-down-num\" min=\"0\" max=\"999\" value=\"2\" />\n </div>\n <div class=\"row\">\n <span>Siblings</span>\n <input type=\"range\" id=\"ctx-sib\" min=\"0\" max=\"20\" value=\"0\" />\n <input type=\"number\" id=\"ctx-sib-num\" min=\"0\" max=\"999\" value=\"0\" />\n </div>\n <div class=\"ctx-summary\" id=\"ctx-summary\">—</div>\n </div>\n\n <div id=\"chat\" style=\"display:none\">\n <div id=\"chat-log\"></div>\n <div id=\"chat-input\">\n <textarea id=\"chat-text\" placeholder=\"Ask, plan, debate this level… (Ctrl+Enter to send)\"></textarea>\n <button id=\"chat-send\" class=\"primary\">Send</button>\n </div>\n </div>\n </aside>\n</div>\n\n<div id=\"settings-overlay\">\n <div id=\"settings-modal\">\n <h3>Reasoning model API</h3>\n <p style=\"font-size:12px;color:var(--muted);margin:0 0 8px\">\n OpenAI-compatible endpoint. Works with DeepSeek (deepseek-reasoner), OpenAI (o1/o3/gpt-5), Anthropic-compatible proxies, local Ollama, etc.\n </p>\n <label>Endpoint URL</label>\n <input type=\"text\" id=\"cfg-endpoint\" placeholder=\"https://api.deepseek.com/v1/chat/completions\" />\n <label>Model</label>\n <input type=\"text\" id=\"cfg-model\" placeholder=\"deepseek-reasoner\" />\n <label>API Key</label>\n <input type=\"text\" id=\"cfg-key\" placeholder=\"sk-…\" />\n <label>System prompt</label>\n <textarea id=\"cfg-system\">You are a parliamentary-procedure-aware planning assistant. The user is editing a hierarchical project outline. Reason carefully and step-by-step. Reference parent context, current node, and child sub-points. Suggest motions, amendments, decisions, and concrete action items. Be precise and structured.</textarea>\n <label><input type=\"checkbox\" id=\"cfg-stream\" checked style=\"width:auto;margin-right:6px\" />Stream responses</label>\n <div class=\"actions\">\n <button id=\"cfg-cancel\">Cancel</button>\n <button id=\"cfg-save\" class=\"primary\">Save</button>\n </div>\n </div>\n</div>\n\n<script>\n'use strict';\n\n// ---------- State ----------\nconst STORE_KEY = 'fdg-zui-state-v1';\nconst CFG_KEY = 'fdg-zui-config-v1';\n\nconst KIND_COLORS = {\n motion: '#ff6b6b',\n second: '#ffa94d',\n amendment: '#ffd43b',\n debate: '#74c0fc',\n decision: '#51cf66',\n action: '#b197fc',\n note: '#adb5bd',\n};\nconst KIND_SHAPES = {\n motion: 'hexagon', second: 'dot', amendment: 'diamond',\n debate: 'dot', decision: 'star', action: 'square', note: 'dot',\n};\n\nlet state = loadState() || {\n nodes: {}, // id -> { id, parent, title, notes, kind, chat: [{role,content,reasoning?}], ctx: {up,down,sib} }\n rootOrder: [],\n};\nlet config = loadConfig() || {\n endpoint: 'https://api.deepseek.com/v1/chat/completions',\n model: 'deepseek-reasoner',\n key: '',\n system: 'You are a parliamentary-procedure-aware planning assistant. The user is editing a hierarchical project outline. Reason carefully and step-by-step. Reference parent context, current node, and child sub-points. Suggest motions, amendments, decisions, and concrete action items. Be precise and structured.',\n stream: true,\n};\n\nlet selectedId = null;\nlet physicsOn = true;\n\n// ---------- Persistence ----------\nfunction saveState() { localStorage.setItem(STORE_KEY, JSON.stringify(state)); }\nfunction loadState() { try { return JSON.parse(localStorage.getItem(STORE_KEY)); } catch { return null; } }\nfunction saveConfig() { localStorage.setItem(CFG_KEY, JSON.stringify(config)); }\nfunction loadConfig() { try { return JSON.parse(localStorage.getItem(CFG_KEY)); } catch { return null; } }\n\n// ---------- Tree helpers ----------\nfunction uid() { return 'n_' + Math.random().toString(36).slice(2, 10); }\n\nfunction childrenOf(id) {\n return Object.values(state.nodes)\n .filter(n => n.parent === id)\n .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));\n}\nfunction ancestorsOf(id) {\n const out = [];\n let cur = state.nodes[id];\n while (cur && cur.parent) { cur = state.nodes[cur.parent]; if (cur) out.push(cur); }\n return out;\n}\nfunction descendantsOf(id, depth) {\n const out = [];\n function walk(pid, d) {\n if (d <= 0) return;\n for (const c of childrenOf(pid)) { out.push({ node: c, depth: depth - d + 1 }); walk(c.id, d - 1); }\n }\n walk(id, depth);\n return out;\n}\nfunction siblingsOf(id) {\n const n = state.nodes[id]; if (!n) return [];\n return Object.values(state.nodes).filter(x => x.parent === n.parent && x.id !== id);\n}\nfunction depthOf(id) { return ancestorsOf(id).length; }\nfunction pathOf(id) {\n const a = ancestorsOf(id).reverse();\n const n = state.nodes[id];\n return [...a.map(x => x.title), n ? n.title : ''].join(' › ');\n}\n\nfunction addNode({ parent = null, title = 'New item', kind = null } = {}) {\n const id = uid();\n const parentNode = parent ? state.nodes[parent] : null;\n const inferredKind = kind || (parentNode ? defaultChildKind(parentNode.kind) : 'motion');\n const order = parent ? childrenOf(parent).length : state.rootOrder.length;\n state.nodes[id] = {\n id, parent, title, notes: '', kind: inferredKind,\n chat: [], ctx: { up: 2, down: 2, sib: 0 }, order,\n };\n if (!parent) state.rootOrder.push(id);\n saveState();\n return id;\n}\nfunction defaultChildKind(parentKind) {\n if (parentKind === 'motion') return 'amendment';\n if (parentKind === 'amendment') return 'debate';\n if (parentKind === 'debate') return 'decision';\n if (parentKind === 'decision') return 'action';\n return 'note';\n}\nfunction deleteNode(id) {\n const kids = childrenOf(id);\n for (const c of kids) deleteNode(c.id);\n const n = state.nodes[id];\n if (n && !n.parent) state.rootOrder = state.rootOrder.filter(x => x !== id);\n delete state.nodes[id];\n saveState();\n}\nfunction promoteNode(id) {\n const n = state.nodes[id]; if (!n || !n.parent) return;\n const grand = state.nodes[n.parent]?.parent ?? null;\n n.parent = grand;\n if (!grand) state.rootOrder.push(id);\n saveState();\n}\n\n// ---------- Graph rendering ----------\nlet network = null;\nlet nodesDS = new vis.DataSet([]);\nlet edgesDS = new vis.DataSet([]);\n\nfunction rebuildGraph() {\n const visNodes = [];\n const visEdges = [];\n for (const n of Object.values(state.nodes)) {\n const depth = depthOf(n.id);\n const size = Math.max(10, 28 - depth * 2);\n visNodes.push({\n id: n.id,\n label: truncate(n.title, 28),\n shape: KIND_SHAPES[n.kind] || 'dot',\n size,\n color: { background: KIND_COLORS[n.kind] || '#888', border: '#ffffff22' },\n borderWidth: selectedId === n.id ? 3 : 1,\n font: { color: '#e6e9ee', size: Math.max(10, 14 - depth) },\n });\n if (n.parent) {\n visEdges.push({\n id: `${n.parent}->${n.id}`,\n from: n.parent, to: n.id,\n color: { color: '#3a4250', opacity: 0.6 },\n smooth: { type: 'continuous' },\n });\n }\n }\n nodesDS.clear(); nodesDS.add(visNodes);\n edgesDS.clear(); edgesDS.add(visEdges);\n}\n\nfunction initNetwork() {\n const container = document.getElementById('mynetwork');\n network = new vis.Network(container, { nodes: nodesDS, edges: edgesDS }, {\n nodes: { font: { color: '#e6e9ee', face: 'Inter, sans-serif' } },\n edges: { color: '#3a4250', smooth: { type: 'continuous' } },\n physics: {\n enabled: true,\n barnesHut: { gravitationalConstant: -3000, springLength: 110, springConstant: 0.04, damping: 0.3 },\n stabilization: { iterations: 80 },\n },\n interaction: { hover: true, multiselect: false, navigationButtons: false },\n });\n network.on('click', (params) => {\n if (params.nodes.length) selectNode(params.nodes[0]);\n else { selectedId = null; renderSide(); rebuildGraph(); }\n });\n network.on('doubleClick', (params) => {\n if (params.nodes.length) {\n const id = params.nodes[0];\n const newTitle = prompt('Rename:', state.nodes[id].title);\n if (newTitle != null) { state.nodes[id].title = newTitle; saveState(); rebuildGraph(); renderSide(); }\n }\n });\n}\n\nfunction truncate(s, n) { return (s || '').length > n ? s.slice(0, n - 1) + '…' : (s || ''); }\n\n// ---------- Side panel ----------\nfunction selectNode(id) { selectedId = id; renderSide(); rebuildGraph(); updateToolbarButtons(); }\n\nfunction renderSide() {\n const empty = document.getElementById('empty');\n const header = document.querySelector('#side header');\n const body = document.getElementById('node-body');\n const ctx = document.getElementById('context-controls');\n const chat = document.getElementById('chat');\n\n if (!selectedId || !state.nodes[selectedId]) {\n empty.style.display = 'block';\n header.style.display = 'none';\n body.style.display = 'none';\n ctx.style.display = 'none';\n chat.style.display = 'none';\n return;\n }\n empty.style.display = 'none';\n header.style.display = 'block';\n body.style.display = 'block';\n ctx.style.display = 'grid';\n chat.style.display = 'flex';\n\n const n = state.nodes[selectedId];\n document.getElementById('crumbs').textContent = pathOf(n.id) || '(root)';\n document.getElementById('node-title').textContent = n.title;\n document.getElementById('node-kind').value = n.kind;\n document.getElementById('node-notes').value = n.notes || '';\n\n const setCtx = (key, idR, idN) => {\n document.getElementById(idR).value = n.ctx[key];\n document.getElementById(idN).value = n.ctx[key];\n };\n setCtx('up', 'ctx-up', 'ctx-up-num');\n setCtx('down', 'ctx-down', 'ctx-down-num');\n setCtx('sib', 'ctx-sib', 'ctx-sib-num');\n updateCtxSummary();\n\n renderChat();\n}\n\nfunction updateCtxSummary() {\n const n = state.nodes[selectedId]; if (!n) return;\n const up = ancestorsOf(n.id).slice(0, n.ctx.up).length;\n const down = descendantsOf(n.id, n.ctx.down).length;\n const sib = Math.min(siblingsOf(n.id).length, n.ctx.sib);\n document.getElementById('ctx-summary').textContent =\n `Context: ${up} ancestor${up===1?'':'s'} · ${down} descendant${down===1?'':'s'} · ${sib} sibling${sib===1?'':'s'}`;\n}\n\nfunction renderChat() {\n const log = document.getElementById('chat-log');\n log.innerHTML = '';\n const n = state.nodes[selectedId]; if (!n) return;\n for (const m of n.chat) {\n const div = document.createElement('div');\n div.className = 'msg ' + m.role;\n const role = document.createElement('div');\n role.className = 'role'; role.textContent = m.role;\n div.appendChild(role);\n if (m.reasoning) {\n const det = document.createElement('details');\n det.className = 'reason';\n const sum = document.createElement('summary');\n sum.textContent = '🧠 reasoning';\n det.appendChild(sum);\n const pre = document.createElement('div');\n pre.textContent = m.reasoning;\n det.appendChild(pre);\n div.appendChild(det);\n }\n const content = document.createElement('div');\n content.textContent = m.content;\n div.appendChild(content);\n log.appendChild(div);\n }\n log.scrollTop = log.scrollHeight;\n}\n\n// ---------- Context assembly ----------\nfunction buildContextString(nodeId) {\n const n = state.nodes[nodeId]; if (!n) return '';\n const lines = [];\n const ancestors = ancestorsOf(n.id).slice(0, n.ctx.up).reverse();\n if (ancestors.length) {\n lines.push('## Ancestor chain (root → parent)');\n ancestors.forEach((a, i) => {\n lines.push(`${' '.repeat(i)}- [${a.kind}] ${a.title}${a.notes ? ` — ${a.notes}` : ''}`);\n });\n }\n lines.push('');\n lines.push(`## Current node (depth ${depthOf(n.id)})`);\n lines.push(`- [${n.kind}] **${n.title}**`);\n if (n.notes) lines.push(` Notes: ${n.notes}`);\n\n if (n.ctx.sib > 0) {\n const sibs = siblingsOf(n.id).slice(0, n.ctx.sib);\n if (sibs.length) {\n lines.push('');\n lines.push('## Siblings');\n sibs.forEach(s => lines.push(`- [${s.kind}] ${s.title}${s.notes ? ` — ${s.notes}` : ''}`));\n }\n }\n\n if (n.ctx.down > 0) {\n const desc = descendantsOf(n.id, n.ctx.down);\n if (desc.length) {\n lines.push('');\n lines.push(`## Descendants (down to ${n.ctx.down} level${n.ctx.down===1?'':'s'})`);\n for (const { node, depth } of desc) {\n lines.push(`${' '.repeat(depth)}- [${node.kind}] ${node.title}${node.notes ? ` — ${node.notes}` : ''}`);\n }\n }\n }\n return lines.join('\\n');\n}\n\n// ---------- Chat ----------\nasync function sendChat() {\n const n = state.nodes[selectedId]; if (!n) return;\n const txt = document.getElementById('chat-text');\n const msg = txt.value.trim(); if (!msg) return;\n txt.value = '';\n\n if (!config.key || !config.endpoint) {\n alert('Configure your API endpoint and key first (⚙ API).');\n openSettings(); return;\n }\n\n n.chat.push({ role: 'user', content: msg });\n renderChat(); saveState();\n\n const ctxText = buildContextString(n.id);\n const systemMsg = `${config.system}\\n\\n# Project outline context\\n${ctxText}`;\n\n // Build message thread from this node's history\n const messages = [{ role: 'system', content: systemMsg }];\n for (const m of n.chat) messages.push({ role: m.role, content: m.content });\n\n const placeholder = { role: 'assistant', content: '', reasoning: '' };\n n.chat.push(placeholder);\n renderChat();\n\n try {\n if (config.stream) {\n await streamCompletion(messages, (deltaContent, deltaReasoning) => {\n if (deltaContent) placeholder.content += deltaContent;\n if (deltaReasoning) placeholder.reasoning += deltaReasoning;\n renderChat();\n });\n } else {\n const res = await fetch(config.endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.key}` },\n body: JSON.stringify({ model: config.model, messages, stream: false }),\n });\n const json = await res.json();\n const choice = json.choices?.[0]?.message || {};\n placeholder.content = choice.content || '';\n placeholder.reasoning = choice.reasoning_content || choice.reasoning || '';\n }\n } catch (e) {\n placeholder.content = `⚠️ Error: ${e.message}`;\n }\n if (!placeholder.reasoning) delete placeholder.reasoning;\n saveState(); renderChat();\n}\n\nasync function streamCompletion(messages, onDelta) {\n const res = await fetch(config.endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.key}` },\n body: JSON.stringify({ model: config.model, messages, stream: true }),\n });\n if (!res.ok || !res.body) {\n const text = await res.text().catch(() => '');\n throw new Error(`${res.status} ${res.statusText} ${text.slice(0, 200)}`);\n }\n const reader = res.body.getReader();\n const dec = new TextDecoder();\n let buf = '';\n while (true) {\n const { value, done } = await reader.read();\n if (done) break;\n buf += dec.decode(value, { stream: true });\n let idx;\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx).trim();\n buf = buf.slice(idx + 1);\n if (!line.startsWith('data:')) continue;\n const payload = line.slice(5).trim();\n if (payload === '[DONE]') return;\n try {\n const j = JSON.parse(payload);\n const d = j.choices?.[0]?.delta || {};\n onDelta(d.content || '', d.reasoning_content || d.reasoning || '');\n } catch { /* ignore */ }\n }\n }\n}\n\n// ---------- Wiring ----------\nfunction updateToolbarButtons() {\n const has = !!selectedId;\n document.getElementById('btn-add-child').disabled = !has;\n document.getElementById('btn-add-sibling').disabled = !has;\n document.getElementById('btn-delete').disabled = !has;\n document.getElementById('btn-promote').disabled = !has || !state.nodes[selectedId]?.parent;\n}\n\ndocument.getElementById('btn-add-root').onclick = () => {\n const id = addNode({ parent: null, title: 'New motion', kind: 'motion' });\n rebuildGraph(); selectNode(id);\n};\ndocument.getElementById('btn-add-child').onclick = () => {\n if (!selectedId) return;\n const id = addNode({ parent: selectedId, title: 'New child' });\n rebuildGraph(); selectNode(id);\n};\ndocument.getElementById('btn-add-sibling').onclick = () => {\n const n = state.nodes[selectedId]; if (!n) return;\n const id = addNode({ parent: n.parent, title: 'New sibling', kind: n.kind });\n rebuildGraph(); selectNode(id);\n};\ndocument.getElementById('btn-delete').onclick = () => {\n if (!selectedId) return;\n if (!confirm('Delete this node and all its children?')) return;\n deleteNode(selectedId); selectedId = null; rebuildGraph(); renderSide(); updateToolbarButtons();\n};\ndocument.getElementById('btn-promote').onclick = () => {\n if (!selectedId) return;\n promoteNode(selectedId); rebuildGraph(); renderSide();\n};\ndocument.getElementById('btn-fit').onclick = () => network && network.fit({ animation: true });\ndocument.getElementById('btn-physics').onclick = (e) => {\n physicsOn = !physicsOn;\n network.setOptions({ physics: { enabled: physicsOn } });\n e.target.textContent = `Physics: ${physicsOn ? 'On' : 'Off'}`;\n};\ndocument.getElementById('btn-export').onclick = () => {\n const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });\n const a = document.createElement('a');\n a.href = URL.createObjectURL(blob);\n a.download = `fdg-zui-${new Date().toISOString().slice(0,10)}.json`;\n a.click();\n};\ndocument.getElementById('btn-import').onclick = () => document.getElementById('file-input').click();\ndocument.getElementById('file-input').onchange = (e) => {\n const f = e.target.files[0]; if (!f) return;\n const r = new FileReader();\n r.onload = () => {\n try {\n const j = JSON.parse(r.result);\n if (!j.nodes) throw new Error('invalid file');\n state = j; saveState(); selectedId = null; rebuildGraph(); renderSide();\n } catch (err) { alert('Import failed: ' + err.message); }\n };\n r.readAsText(f);\n};\n\n// Title / kind / notes editing\ndocument.getElementById('node-title').addEventListener('blur', (e) => {\n if (!selectedId) return;\n state.nodes[selectedId].title = e.target.textContent.trim() || 'Untitled';\n saveState(); rebuildGraph();\n});\ndocument.getElementById('node-title').addEventListener('keydown', (e) => {\n if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); }\n});\ndocument.getElementById('node-kind').addEventListener('change', (e) => {\n if (!selectedId) return;\n state.nodes[selectedId].kind = e.target.value;\n saveState(); rebuildGraph();\n});\ndocument.getElementById('node-notes').addEventListener('input', (e) => {\n if (!selectedId) return;\n state.nodes[selectedId].notes = e.target.value;\n saveState();\n});\n\n// Context sliders\nfunction bindCtxPair(rangeId, numId, key) {\n const r = document.getElementById(rangeId);\n const n = document.getElementById(numId);\n const sync = (val) => {\n if (!selectedId) return;\n const v = Math.max(0, parseInt(val, 10) || 0);\n state.nodes[selectedId].ctx[key] = v;\n r.value = Math.min(v, parseInt(r.max));\n n.value = v;\n saveState(); updateCtxSummary();\n };\n r.addEventListener('input', () => sync(r.value));\n n.addEventListener('input', () => sync(n.value));\n}\nbindCtxPair('ctx-up', 'ctx-up-num', 'up');\nbindCtxPair('ctx-down', 'ctx-down-num', 'down');\nbindCtxPair('ctx-sib', 'ctx-sib-num', 'sib');\n\n// Chat send\ndocument.getElementById('chat-send').onclick = sendChat;\ndocument.getElementById('chat-text').addEventListener('keydown', (e) => {\n if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendChat(); }\n});\n\n// Settings\nfunction openSettings() {\n document.getElementById('cfg-endpoint').value = config.endpoint;\n document.getElementById('cfg-model').value = config.model;\n document.getElementById('cfg-key').value = config.key;\n document.getElementById('cfg-system').value = config.system;\n document.getElementById('cfg-stream').checked = !!config.stream;\n document.getElementById('settings-overlay').classList.add('visible');\n}\nfunction closeSettings() { document.getElementById('settings-overlay').classList.remove('visible'); }\ndocument.getElementById('btn-settings').onclick = openSettings;\ndocument.getElementById('cfg-cancel').onclick = closeSettings;\ndocument.getElementById('cfg-save').onclick = () => {\n config = {\n endpoint: document.getElementById('cfg-endpoint').value.trim(),\n model: document.getElementById('cfg-model').value.trim(),\n key: document.getElementById('cfg-key').value.trim(),\n system: document.getElementById('cfg-system').value,\n stream: document.getElementById('cfg-stream').checked,\n };\n saveConfig(); closeSettings();\n};\n\n// Global keyboard shortcuts\ndocument.addEventListener('keydown', (e) => {\n const t = e.target;\n const editing = t.matches('input, textarea, [contenteditable=\"true\"]');\n if (editing) return;\n if (!selectedId && !['n', 'N'].includes(e.key)) return;\n if (e.key === 'Tab' && !e.shiftKey) {\n e.preventDefault();\n const id = addNode({ parent: selectedId, title: 'New child' });\n rebuildGraph(); selectNode(id);\n } else if (e.key === 'Tab' && e.shiftKey) {\n e.preventDefault(); promoteNode(selectedId); rebuildGraph(); renderSide();\n } else if (e.key === 'Enter') {\n e.preventDefault();\n const n = state.nodes[selectedId];\n const id = addNode({ parent: n?.parent ?? null, title: 'New sibling', kind: n?.kind });\n rebuildGraph(); selectNode(id);\n } else if (e.key === 'Delete' || e.key === 'Backspace') {\n if (!confirm('Delete this node and all its children?')) return;\n deleteNode(selectedId); selectedId = null; rebuildGraph(); renderSide(); updateToolbarButtons();\n }\n});\n\n// ---------- Bootstrap ----------\nfunction seedIfEmpty() {\n if (Object.keys(state.nodes).length) return;\n const root = addNode({ parent: null, title: 'Project: Launch new product', kind: 'motion' });\n const a = addNode({ parent: root, title: 'Define MVP scope', kind: 'amendment' });\n addNode({ parent: a, title: 'Discuss must-haves vs nice-to-haves', kind: 'debate' });\n addNode({ parent: a, title: 'Lock scope by Friday', kind: 'decision' });\n const b = addNode({ parent: root, title: 'Resource allocation', kind: 'amendment' });\n addNode({ parent: b, title: 'Hire 2 engineers', kind: 'action' });\n addNode({ parent: root, title: 'Risk: timeline slippage', kind: 'note' });\n}\n\ninitNetwork();\nseedIfEmpty();\nrebuildGraph();\nrenderSide();\nupdateToolbarButtons();\nnetwork.once('stabilizationIterationsDone', () => network.fit());\n</script>\n</body>\n</html>\n"}, "c8ac708342dd14c9e8637047fc2bfb3334d55e4a": {"t": 1785765934.9276292, "n": 7967, "h": "5ac7b6f6f34ce6c6", "c": "<!DOCTYPE html>\n<html>\n<head>\n <title>LinkedIn Social Orbit Map</title>\n <script type=\"text/javascript\" src=\"https://unpkg.com/vis-network/standalone/umd/vis-network.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.3.0/papaparse.min.js\"></script> <style>\n body { margin: 0; background: #0f0f0f; color: #e0e0e0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; overflow: hidden; }\n #mynetwork { width: 100vw; height: 100vh; }\n \n /* The CRM Overlay Card */\n #crm-panel {\n position: absolute; top: 20px; left: 20px; width: 320px;\n background: rgba(25, 25, 25, 0.9); padding: 25px;\n border-left: 4px solid #00d2ff;\n backdrop-filter: blur(15px); box-shadow: 10px 10px 30px rgba(0,0,0,0.5);\n display: none; transition: all 0.2s ease;\n }\n h2 { margin: 0 0 10px 0; font-size: 22px; color: #fff; letter-spacing: 0.5px; }\n .meta-row { display: flex; justify-content: space-between; margin-bottom: 15px; font-size: 13px; color: #888; }\n \n /* Dynamic Status Badge */\n #status-badge {\n padding: 4px 12px; border-radius: 20px; font-weight: bold; font-size: 11px; text-transform: uppercase; letter-spacing: 1px;\n }\n \n /* Action Buttons */\n .actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 20px; }\n button {\n background: #333; border: 1px solid #555; color: #fff; padding: 10px; cursor: pointer;\n border-radius: 4px; font-size: 12px; transition: 0.2s;\n }\n button:hover { background: #444; border-color: #777; }\n button.primary { background: #0066cc; border: none; }\n button.primary:hover { background: #0052a3; }\n\n #loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); pointer-events: none; }\n </style>\n</head>\n<body>\n\n<div id=\"loading\">Loading Mission Data...</div>\n\n<div id=\"crm-panel\">\n <div class=\"meta-row\">\n <span id=\"node-role\">ROLE</span>\n <span id=\"status-badge\">STATUS</span>\n </div>\n <h2 id=\"node-label\">Name</h2>\n <p style=\"font-size: 14px; margin-bottom: 20px;\">\n <a id=\"node-link\" href=\"#\" target=\"_blank\" style=\"color:#00d2ff; text-decoration:none\">Open LinkedIn Profile ↗</a>\n </p>\n \n <div class=\"actions\">\n <button onclick=\"triggerAction('invited')\">Mark Invited</button>\n <button class=\"primary\" onclick=\"triggerAction('connected')\">Mark Connected</button>\n <button onclick=\"triggerAction('marinate')\">Start Marinating</button>\n <button onclick=\"triggerAction('intro_ready')\" style=\"color:#ff4d4d\">Request Intro</button>\n </div>\n</div>\n\n<div id=\"mynetwork\"></div>\n\n<script type=\"text/javascript\">\n // ==========================================\n // 1. CONFIGURATION (PASTE YOUR LINKS HERE)\n // ==========================================\n const GOOGLE_SHEET_CSV_URL = \"PASTE_YOUR_PUBLISHED_CSV_LINK_HERE\"; \n const N8N_WEBHOOK_URL = \"PASTE_YOUR_N8N_WEBHOOK_URL_HERE\"; \n\n // Visual Settings for Roles\n const COLOR_MAP = {\n 'Center': '#ffffff', // White\n 'P-0': '#ff4757', // Red (Target)\n 'P-1': '#ffa502', // Orange\n 'P-2': '#eccc68', // Gold\n 'P-3': '#2ed573', // Green\n 'P-4': '#1e90ff', // Blue\n 'P-6': '#a4b0be' // Grey\n };\n\n let network = null;\n let allNodes = [];\n\n // ==========================================\n // 2. DATA LOADING & PARSING\n // ==========================================\n Papa.parse(GOOGLE_SHEET_CSV_URL, {\n download: true,\n header: true,\n complete: function(results) {\n document.getElementById('loading').style.display = 'none';\n buildGraph(results.data);\n }\n });\n\n function buildGraph(rawData) {\n const nodes = [];\n const edges = [];\n\n rawData.forEach(row => {\n if(!row.ID) return; // Skip empty rows\n\n // 1. Create Node\n nodes.push({\n id: row.ID,\n label: row.Name,\n group: row.Role,\n status: row.Status,\n url: row.LinkedIn_URL,\n shape: row.Role === 'Center' ? 'hexagon' : 'dot',\n size: row.Role === 'Center' ? 40 : 20,\n borderWidth: row.Status === 'Target' ? 4 : 1, // Thick border for targets\n color: {\n background: COLOR_MAP[row.Role] || '#ccc',\n border: row.Status === 'Connected' ? '#2ed573' : '#fff' // Green border if connected\n }\n });\n\n // 2. Create Edges (Relationships)\n if(row.Connected_To_IDs) {\n const targetIds = row.Connected_To_IDs.split(',');\n targetIds.forEach(targetId => {\n edges.push({ from: row.ID, to: targetId.trim() });\n });\n }\n });\n\n drawNetwork(nodes, edges);\n }\n\n // ==========================================\n // 3. VISUALIZATION ENGINE\n // ==========================================\n function drawNetwork(nodes, edges) {\n const container = document.getElementById('mynetwork');\n const data = { nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) };\n const options = {\n nodes: { font: { color: '#fff', size: 14, face: 'arial' } },\n edges: { color: '#333', smooth: { type: 'continuous' } },\n physics: {\n stabilization: false,\n barnesHut: { gravitationalConstant: -4000, springLength: 100 }\n }\n };\n\n network = new vis.Network(container, data, options);\n allNodes = nodes; // Store for lookup\n\n // Click Handler\n network.on(\"click\", function (params) {\n if (params.nodes.length > 0) {\n const nodeId = params.nodes[0];\n const node = allNodes.find(n => n.id === nodeId);\n showCRM(node);\n } else {\n document.getElementById('crm-panel').style.display = 'none';\n }\n });\n }\n\n // ==========================================\n // 4. CRM LOGIC\n // ==========================================\n let currentUserNode = null;\n\n function showCRM(node) {\n currentUserNode = node;\n const panel = document.getElementById('crm-panel');\n \n document.getElementById('node-label').innerText = node.label;\n document.getElementById('node-role').innerText = node.group;\n document.getElementById('node-link').href = node.url || \"#\";\n \n const badge = document.getElementById('status-badge');\n badge.innerText = node.status;\n badge.style.background = node.status === 'Connected' ? '#2ed573' : '#555';\n\n panel.style.display = 'block';\n }\n\n function triggerAction(actionType) {\n if(!currentUserNode) return;\n\n // Visual Feedback\n const originalText = event.target.innerText;\n event.target.innerText = \"Sending...\";\n\n // Send to n8n\n fetch(N8N_WEBHOOK_URL, {\n method: 'POST',\n headers: {'Content-Type': 'application/json'},\n body: JSON.stringify({\n id: currentUserNode.id,\n name: currentUserNode.label,\n action: actionType,\n timestamp: new Date().toISOString()\n })\n })\n .then(response => {\n event.target.innerText = \"Done!\";\n setTimeout(() => event.target.innerText = originalText, 2000);\n alert(`Webhook sent to n8n: Update ${currentUserNode.label} to ${actionType}`);\n })\n .catch(error => {\n console.error('Error:', error);\n event.target.innerText = \"Failed\";\n });\n }\n</script>\n</body>\n</html>\n"}}}} |