Files

1 line
73 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}