// Main App function App(){ const initial = { title: '', subtitle: '', showTitleOnCanvas: false, bgPattern: 'bg-dots', bgColor: '#161922', nodes: [], edges: [], groups: [], notes: [], // card annotation boxes (PC-only feature) showNotes: true, // global toggle: hide all note boxes + connectors // Flow animation runs on every arrow. Default OFF; users opt in via the // "Background & decoration" popover. Sample demo turns it on explicitly so first-time // users see the effect at least once. showFlow: false, // User-learned action vocabulary. Auto-populated when users type a custom // label — the label/color/stamp combo gets saved as a convenience button // for future arrows. Stored per-project (travels with .combo.json). // Items: { id, label, color, stamp } customRelations: [], }; const hist = useHistory(initial); const { state, set } = hist; const [selection, setSelection] = React.useState(null); const [view, setView] = React.useState({ zoom: 1, tx: 200, ty: 120 }); const [editingEdgeId, setEditingEdgeId] = React.useState(null); const [toast, setToast] = React.useState(null); const [shotMode, setShotMode] = React.useState(false); // Group compose mode: when non-null, clicking a node toggles its membership in that group. const [composingGroupId, setComposingGroupId] = React.useState(null); // Branch-node mode: when true, clicking an edge places a small branch node // at that spot. Exits via Esc or toggling the toolbar button again. const [branchNodeMode, setBranchNodeMode] = React.useState(false); const shotPrevViewRef = React.useRef(null); const canvasRef = React.useRef(null); // ---- toast helper const flash = (msg)=>{ setToast(msg); setTimeout(()=>setToast(null), 1800); }; // ---- mutations const nextId = (prefix) => prefix + '_' + Math.random().toString(36).slice(2,8); const addNode = (partial={}) => { // Place near center of current view const stage = canvasRef.current; const rect = stage?.getBoundingClientRect(); const cx = rect ? (rect.width/2 - view.tx)/view.zoom : 400; const cy = rect ? (rect.height/2 - view.ty)/view.zoom : 300; const color = NODE_COLORS[state.nodes.length % NODE_COLORS.length]; const id = nextId('n'); set(s => ({ ...s, nodes:[...s.nodes, { id, x: cx + (Math.random()-0.5)*80, y: cy + (Math.random()-0.5)*80, name:'New card', color, emoji: null, image: null, shape:'square', ...partial, }] })); setSelection({type:'node', id}); }; const updateNode = (id, patch, opts) => { set(s => ({ ...s, nodes: s.nodes.map(n => n.id===id ? { ...n, ...patch } : n), }), opts); }; // Add a small branch node attached to an edge at parameter t along the curve. // The branch stays glued to the arrow: moving the arrow's endpoints drags // the branch along, and the user can slide it by dragging. x/y are stored // as fallback for when the attached edge later gets deleted — in that case // the branch detaches and remains at its last rendered position. const addBranchNodeOnEdge = (edgeId, t, x, y) => { const id = nextId('n'); set(s => ({ ...s, nodes:[...s.nodes, { id, x, y, variant:'branch', shape:'circle', name:'', subtitle:'', color:'#8A8D9E', emoji:null, image:null, attachedEdgeId: edgeId, t: t, }] })); setSelection({type:'node', id}); }; // Add a branch node placed inside a zone — registered as a regular member // of that group's nodeIds, so the metaball blob naturally extends to include // the branch (no special projection / inset math needed). The branch's // initial color snapshots the group color at placement time; subsequent edits // are independent (group recolors don't follow). Branches are exclusive to // a single group — they have no separate existence outside it. const addBranchNodeOnGroup = (groupId, x, y) => { const id = nextId('n'); set(s => { const g = (s.groups || []).find(gg => gg.id === groupId); const groupColor = g?.color || '#8A8D9E'; return { ...s, nodes:[...s.nodes, { id, x, y, variant:'branch', shape:'circle', name:'', subtitle:'', color: groupColor, emoji:null, image:null, }], groups: (s.groups || []).map(gg => gg.id === groupId ? { ...gg, nodeIds: [...(gg.nodeIds || []), id] } : gg), }; }); setSelection({type:'node', id}); }; // Cascade-delete helper: given a state and a set of explicit victims (nodes, // edges, or groups), iterate to a fixed point and return a new state with // every dependent removal applied. Rules: // • dying node → its incident edges die // • dying edge → edge-attached branches die, edge-targeted notes die // • dying group → its branch members die (branches can't exist alone) // • group whose non-branch members hit 0 → group dies (then its branches) // Pass victims as { nodes:[], edges:[], groups:[] }. const applyCascadeDelete = (s, victims) => { const dyingNodes = new Set(victims.nodes || []); const dyingEdges = new Set(victims.edges || []); const dyingGroups = new Set(victims.groups || []); // Seed: branches in explicitly deleted groups are already dying. dyingGroups.forEach(gid => { const g = (s.groups || []).find(gg => gg.id === gid); (g?.nodeIds || []).forEach(nid => { const n = s.nodes.find(nn => nn.id === nid); if (n && n.variant === 'branch') dyingNodes.add(n.id); }); }); let changed = true; while (changed) { changed = false; // dying nodes → incident edges die s.edges.forEach(e => { if (dyingEdges.has(e.id)) return; if (dyingNodes.has(e.from) || dyingNodes.has(e.to)) { dyingEdges.add(e.id); changed = true; } }); // dying edges → edge-attached branches die s.nodes.forEach(n => { if (dyingNodes.has(n.id)) return; if (n.variant === 'branch' && n.attachedEdgeId && dyingEdges.has(n.attachedEdgeId)) { dyingNodes.add(n.id); changed = true; } }); // groups whose non-branch members hit 0 → group dies (then its branches) (s.groups || []).forEach(g => { if (dyingGroups.has(g.id)) return; const liveNonBranch = (g.nodeIds || []).filter(nid => { if (dyingNodes.has(nid)) return false; const n = s.nodes.find(nn => nn.id === nid); return n && n.variant !== 'branch'; }); if (liveNonBranch.length === 0) { dyingGroups.add(g.id); (g.nodeIds || []).forEach(nid => { const n = s.nodes.find(nn => nn.id === nid); if (n && n.variant === 'branch' && !dyingNodes.has(n.id)) { dyingNodes.add(n.id); } }); changed = true; } }); } return { ...s, nodes: s.nodes.filter(n => !dyingNodes.has(n.id)), edges: s.edges.filter(e => !dyingEdges.has(e.id)), groups: (s.groups || []) .filter(g => !dyingGroups.has(g.id)) .map(g => ({ ...g, nodeIds: (g.nodeIds || []).filter(nid => !dyingNodes.has(nid)) })), notes: (s.notes || []).filter(n => !dyingNodes.has(n.nodeId) && !dyingEdges.has(n.targetEdgeId)), }; }; // Duplicate a node: copies every property except id, offsets position so the // new card is visibly separate. For regular cards, attached edges / groups / // notes are NOT copied (the user likely wants a fresh instance to wire up // differently). For BRANCH nodes, the copy MUST stay anchored — branches // can't exist alone — so group memberships ARE preserved (edge-attach is // already preserved by the spread). Toast is variant-aware. const duplicateNode = (id) => { const src = state.nodes.find(n => n.id === id); if (!src) return; const newId = nextId('n'); const copy = { ...src, id: newId, x: (src.x || 0) + 30, y: (src.y || 0) + 30 }; const isBranch = src.variant === 'branch'; set(s => { const groups = isBranch ? (s.groups || []).map(g => (g.nodeIds || []).includes(id) ? { ...g, nodeIds: [...g.nodeIds, newId] } : g) : (s.groups || []); return { ...s, nodes: [...s.nodes, copy], groups }; }); setSelection({ type: 'node', id: newId }); flash(isBranch ? 'Branch node duplicated 📋' : 'Card duplicated 📋'); }; const deleteNode = (id) => { set(s => applyCascadeDelete(s, { nodes: [id] })); setSelection(null); }; const addEdge = (fromId, toId, partial={}) => { const id = nextId('e'); set(s => ({ ...s, edges:[...s.edges, { id, from:fromId, to:toId, relation:'summon', style:'oneway', color: RELATIONS[0].color, label: null, stamp: RELATIONS[0].stamp, ...partial, }] })); setSelection({type:'edge', id}); }; const updateEdge = (id, patch, opts) => { set(s => ({ ...s, edges: s.edges.map(e => e.id===id ? { ...e, ...patch } : e), }), opts); }; const deleteEdge = (id) => { // Cascade-delete edge-attached branches + edge-attached notes via the // shared cascade helper. Toast surfaces the side effects so deletion // isn't silently destructive. // // Compute side-effect counts from CURRENT state BEFORE calling set() — // React 18 batches the updater, so any closure mutation inside the set() // callback would run AFTER the synchronous side-effect dispatches below // (the toast wouldn't fire). const attachedBranchCount = state.nodes.filter(n => n.attachedEdgeId === id).length; const attachedNoteCount = (state.notes || []).filter(n => n.targetEdgeId === id).length; set(s => applyCascadeDelete(s, { edges: [id] })); setSelection(null); const parts = []; if (attachedBranchCount > 0) parts.push(`${attachedBranchCount} branch node${attachedBranchCount > 1 ? 's' : ''}`); if (attachedNoteCount > 0) parts.push(`${attachedNoteCount} note${attachedNoteCount > 1 ? 's' : ''}`); if (parts.length > 0) flash(`Arrow deleted (also removed ${parts.join(' / ')})`); }; // -------- Groups (cluster of nodes with a blob visual) -------- const GROUP_COLORS = ['#E9C46A','#F4A261','#6DD3F0','#7FE3A9','#FF6B9D','#C77DFF','#B39DDB']; const createGroupFromNode = (nodeId) => { const id = nextId('g'); const usedColors = (state.groups || []).map(g=>g.color); const color = GROUP_COLORS.find(c => !usedColors.includes(c)) || GROUP_COLORS[0]; set(s => ({ ...s, groups: [...(s.groups || []), { id, name:'', color, showName:true, nodeIds:[nodeId] }], })); return id; }; const updateGroup = (id, patch, opts) => { set(s => ({ ...s, groups: (s.groups || []).map(g => g.id === id ? { ...g, ...patch } : g), }), opts); }; const deleteGroup = (id) => { // Group deletion cascades to its branch members (which can't exist alone), // and from there to any edges incident to those branches plus notes on // those edges. Regular card members survive (they may be in other groups). // // Compute label/count synchronously from current state — see deleteEdge // for the React 18 batching reason. const grp = (state.groups || []).find(x => x.id === id); const groupLabel = grp?.name || ''; const branchMemberCount = (grp?.nodeIds || []).filter(nid => { const n = state.nodes.find(nn => nn.id === nid); return n && n.variant === 'branch'; }).length; set(s => applyCascadeDelete(s, { groups: [id] })); setSelection(null); if (composingGroupId === id) setComposingGroupId(null); if (branchMemberCount > 0) { flash(`"${groupLabel || 'Zone'}" deleted (also removed ${branchMemberCount} attached branch node${branchMemberCount > 1 ? 's' : ''})`); } }; // Remove a regular card from a group via the inspector ×. If this leaves // the group with only branch members (or empty), the group itself // cascade-deletes — branches can't survive alone, and a branches-only zone // is invalid by design. Toast surfaces the cascade so it isn't surprising. const removeNodeFromGroup = (groupId, nodeId) => { // Compute side-effect signals from CURRENT state BEFORE calling set() — // React 18 batches the updater, so closure mutations inside set() would // run AFTER the sync setSelection / flash calls below, leaving the // Inspector stuck on a stale group reference (blank panel). const grp = (state.groups || []).find(g => g.id === groupId); if (!grp) return; const willHaveNonBranch = (grp.nodeIds || []).some(nid => { if (nid === nodeId) return false; const n = state.nodes.find(nn => nn.id === nid); return n && n.variant !== 'branch'; }); const groupDied = !willHaveNonBranch; const dyingBranchCount = groupDied ? (grp.nodeIds || []).filter(nid => { const n = state.nodes.find(nn => nn.id === nid); return n && n.variant === 'branch'; }).length : 0; set(s => { const updated = { ...s, groups: (s.groups || []).map(g => g.id === groupId ? { ...g, nodeIds: (g.nodeIds || []).filter(nid => nid !== nodeId) } : g), }; // Empty victims set: applyCascadeDelete still runs its auto-detect loop // for "groups whose non-branch members hit 0" → branches in those groups // die → incident edges & notes die. return applyCascadeDelete(updated, {}); }); if (groupDied) { if (selection?.type === 'group' && selection.id === groupId) setSelection(null); if (composingGroupId === groupId) setComposingGroupId(null); if (dyingBranchCount > 0) { flash(`"${grp.name || 'Zone'}" deleted (also removed ${dyingBranchCount} remaining branch node${dyingBranchCount > 1 ? 's' : ''})`); } } }; // Toggle a node's membership in a group during compose mode. // Regular cards can be in any number of groups; BRANCH nodes are exclusive // to a single group — toggling them off would orphan them, so we no-op // (with a toast hint) instead. const toggleGroupMember = (groupId, nodeId) => { const node = state.nodes.find(n => n.id === nodeId); if (node && node.variant === 'branch') { flash("Branch nodes can't be removed here (delete them instead)"); return; } set(s => { let groups = (s.groups || []).map(g => { if (g.id !== groupId) return g; const has = (g.nodeIds || []).includes(nodeId); const nodeIds = has ? g.nodeIds.filter(x => x !== nodeId) : [...(g.nodeIds || []), nodeId]; return { ...g, nodeIds }; }); // Auto-delete groups whose non-branch members dropped to 0 (except the // one we're actively composing — keep it open while the user works). const dyingGroupIds = groups .filter(g => g.id !== groupId) .filter(g => { const liveNonBranch = (g.nodeIds || []).filter(nid => { const n = s.nodes.find(nn => nn.id === nid); return n && n.variant !== 'branch'; }); return liveNonBranch.length === 0; }) .map(g => g.id); if (dyingGroupIds.length > 0) { return applyCascadeDelete({ ...s, groups }, { groups: dyingGroupIds }); } return { ...s, groups }; }); }; // ---- note (card annotation) CRUD const addNote = (nodeId) => { const id = nextId('note'); // Offset by slight increments so stacking multiple notes on the same card // fans them out instead of piling directly on top of each other. const siblingCount = (state.notes || []).filter(n => n.nodeId === nodeId).length; const fanOffset = siblingCount * 24; set(s => ({ ...s, notes: [...(s.notes || []), { id, nodeId, text: '', offset: { dx: 140 + fanOffset, dy: 60 + fanOffset }, }], })); setSelection({type:'note', id}); }; // Add an annotation attached to an edge's midpoint instead of a node. // Resolved position = bezier midpoint + offset, recomputed every render so // the note follows when the edge curve / endpoints change. const addNoteOnEdge = (edgeId) => { const id = nextId('note'); const siblingCount = (state.notes || []).filter(n => n.targetEdgeId === edgeId).length; const fanOffset = siblingCount * 24; set(s => ({ ...s, notes: [...(s.notes || []), { id, targetEdgeId: edgeId, text: '', offset: { dx: 80 + fanOffset, dy: -80 + fanOffset }, }], })); setSelection({type:'note', id}); }; const updateNote = (id, patch, opts) => { set(s => ({ ...s, notes: (s.notes || []).map(n => n.id===id ? {...n, ...patch} : n), }), opts); }; const deleteNote = (id) => { set(s => ({ ...s, notes: (s.notes || []).filter(n => n.id !== id) })); setSelection(null); }; // ---- Auto-learned action vocabulary. Whenever the user types a custom // label on an edge, we remember it (along with the current color/stamp) as a // convenience button for future arrows. No explicit "register" UI — it just // accumulates. Users can prune with the × button on each custom chip. const learnRelation = (label, color, stamp) => { const normalized = (label || '').trim(); if (!normalized) return; // Skip if the label already matches a built-in preset (no point duplicating). if (RELATIONS.some(r => r.label.trim() === normalized)) return; const customs = state.customRelations || []; // Case-insensitive dedup against existing custom entries. if (customs.some(r => r.label.trim().toLowerCase() === normalized.toLowerCase())) return; const id = 'c_' + Math.random().toString(36).slice(2, 8); set(s => ({ ...s, customRelations: [...(s.customRelations || []), { id, label: normalized, color: color || '#DDDDDD', stamp: stamp || '' }], })); // Visual feedback is handled by the chip's gold-pulse animation in Inspector, // so no toast noise is needed here (works silently for repeat additions too). }; const removeCustomRelation = (id) => { set(s => ({ ...s, customRelations: (s.customRelations || []).filter(r => r.id !== id) })); }; // Swap two edges' positions in state.edges — later edges render on top, // so this effectively swaps their z-order. Triggered by Shift+click. const swapEdgeZ = (id1, id2) => { set(s => { const i1 = s.edges.findIndex(e => e.id === id1); const i2 = s.edges.findIndex(e => e.id === id2); if (i1 < 0 || i2 < 0 || i1 === i2) return s; const edges = [...s.edges]; [edges[i1], edges[i2]] = [edges[i2], edges[i1]]; return {...s, edges}; }); }; const clearAll = () => { if (!confirm('Delete every card and arrow. Are you sure?')) return; set(s => ({...s, nodes:[], edges:[], groups:[], notes:[], customRelations:[], title:'', subtitle:'', showTitleOnCanvas:false, showFlow:false, bgPattern:'bg-dots', bgColor:'#161922'})); setSelection(null); setComposingGroupId(null); try { // Must match the key in useHistory — see history.jsx for the rationale. localStorage.removeItem('combo_saved_data'); } catch(e) {} }; // ---- demo data const loadDemo = () => { const demo = buildDemoData(); set(s => ({ ...s, nodes: demo.nodes, edges: demo.edges, groups: demo.groups || [], notes: demo.notes || [], showNotes: true, // Demo explicitly enables flow so first-time users see the animation // in action. Empty/new projects still start with flow off (default). showFlow: demo.showFlow ?? true, title: '1-Card Combo Example', subtitle: 'From a single starter to the final board', showTitleOnCanvas: true, })); // Center the demo in whatever canvas viewport the user currently has — // a hardcoded tx/ty would leave the layout left-aligned on wide monitors. const zoom = 0.85; const rect = canvasRef.current?.getBoundingClientRect(); const vw = rect ? rect.width : 1200; const vh = rect ? rect.height : 700; const xs = demo.nodes.map(n => n.x); const ys = demo.nodes.map(n => n.y); const cx = (Math.min(...xs) + Math.max(...xs)) / 2; const cy = (Math.min(...ys) + Math.max(...ys)) / 2; // Nudge content up a bit so the canvas title banner has headroom. const tx = vw / 2 - cx * zoom; const ty = vh / 2 - cy * zoom - 40; setView({ zoom, tx, ty }); setSelection(null); flash('Sample loaded 💎'); }; // ---- setters bound to state via set() const setTitle = (t)=> set(s=>({...s,title:t})); const setSubtitle = (t)=> set(s=>({...s,subtitle:t})); const setShowTitleOnCanvas = (v)=> set(s=>({...s,showTitleOnCanvas:v})); const setShowNotes = (v)=> set(s=>({...s,showNotes:v})); const setShowFlow = (v)=> set(s=>({...s,showFlow:v})); const setBgPattern = (v)=> set(s=>({...s,bgPattern:v})); const setBgColor = (v)=> set(s=>({...s,bgColor:v})); // ---- project file save / load const saveProject = () => { const data = JSON.stringify(state, null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (state.title || 'combo') + '.combo.json'; a.click(); URL.revokeObjectURL(url); flash('Project saved 💾'); }; const loadProjectFileRef = React.useRef(null); const loadProject = () => { loadProjectFileRef.current?.click(); }; const onLoadProjectFile = (e) => { const f = e.target.files?.[0]; if (!f) return; const reader = new FileReader(); reader.onload = () => { try { const data = JSON.parse(reader.result); if (!confirm('Your current work will be overwritten. Continue?')) return; set(s => ({ ...s, title: data.title ?? '', subtitle: data.subtitle ?? '', showTitleOnCanvas: data.showTitleOnCanvas ?? false, bgPattern: data.bgPattern ?? 'bg-dots', bgColor: data.bgColor ?? '#161922', nodes: data.nodes ?? [], edges: data.edges ?? [], groups: data.groups ?? [], notes: data.notes ?? [], showNotes: data.showNotes ?? true, showFlow: data.showFlow ?? false, customRelations: data.customRelations ?? [], })); setSelection(null); setComposingGroupId(null); setView({ zoom: 1, tx: 200, ty: 120 }); flash('Project loaded 📂'); } catch(err) { flash('Failed to load file'); console.error(err); } }; reader.readAsText(f); e.target.value = ''; }; // ---- screenshot mode const enterShotMode = () => { if (state.nodes.length === 0) { flash('Add a card first'); return; } setSelection(null); const pad = 80; let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity; state.nodes.forEach(n=>{ minX = Math.min(minX, n.x-70); minY = Math.min(minY, n.y-70); maxX = Math.max(maxX, n.x+70); maxY = Math.max(maxY, n.y+70); }); if (state.showTitleOnCanvas) minY = Math.min(minY, 0); minX-=pad; minY-=pad; maxX+=pad; maxY+=pad; const bw = maxX-minX, bh = maxY-minY; const vw = window.innerWidth, vh = window.innerHeight; const zoom = Math.min(1.5, Math.max(0.3, Math.min(vw/bw, vh/bh))); const tx = (vw - bw*zoom)/2 - minX*zoom; const ty = (vh - bh*zoom)/2 - minY*zoom; shotPrevViewRef.current = view; setView({ zoom, tx, ty }); setShotMode(true); document.body.classList.add('is-shot-mode'); }; const exitShotMode = () => { setShotMode(false); document.body.classList.remove('is-shot-mode'); if (shotPrevViewRef.current) { setView(shotPrevViewRef.current); shotPrevViewRef.current = null; } }; // ---- keyboard React.useEffect(()=>{ const onKey = (e) => { if (shotMode && e.key==='Escape') { exitShotMode(); return; } if (e.key==='Escape' && composingGroupId) { setComposingGroupId(null); return; } if (e.key==='Escape' && branchNodeMode) { setBranchNodeMode(false); return; } if (e.target.matches('input,textarea')) return; if ((e.metaKey||e.ctrlKey) && e.key==='z' && !e.shiftKey){ e.preventDefault(); hist.undo(); } else if ((e.metaKey||e.ctrlKey) && (e.key==='y' || (e.shiftKey && e.key==='z'))){ e.preventDefault(); hist.redo(); } else if (e.key==='Delete' || e.key==='Backspace'){ if (selection?.type==='node') deleteNode(selection.id); if (selection?.type==='edge') deleteEdge(selection.id); if (selection?.type==='group') deleteGroup(selection.id); if (selection?.type==='note') deleteNote(selection.id); } }; window.addEventListener('keydown', onKey); return ()=> window.removeEventListener('keydown', onKey); }, [selection, hist, shotMode, composingGroupId, branchNodeMode]); // ---- edge label inline editor const editingEdge = state.edges.find(e=>e.id===editingEdgeId); // Branch placement requires SOMETHING to drop the branch onto — at least one // arrow OR one zone with non-branch members. When there's nothing valid, the // toolbar button is disabled so the user can't enter a no-op mode. const branchModeAvailable = React.useMemo(()=>{ if (state.edges.length > 0) return true; return (state.groups || []).some(g => (g.nodeIds || []).some(nid => { const n = state.nodes.find(nn => nn.id === nid); return n && n.variant !== 'branch'; })); }, [state.edges, state.groups, state.nodes]); // Auto-exit branch mode if all valid drop targets disappear while it's // active (e.g., the user deleted the last arrow / zone via undo). React.useEffect(()=>{ if (branchNodeMode && !branchModeAvailable) setBranchNodeMode(false); }, [branchNodeMode, branchModeAvailable]); return (
addNode()} branchNodeMode={branchNodeMode} branchModeAvailable={branchModeAvailable} onToggleBranchMode={()=>{ if (!branchNodeMode && !branchModeAvailable){ // No valid drop targets exist yet — refuse to enter the mode so // the user isn't stuck in a no-op state. Hint via toast. flash('Add an arrow or a zone first'); return; } setBranchNodeMode(v=>!v); }} onShotMode={enterShotMode} onSaveProject={saveProject} onLoadProject={loadProject} onLoadDemo={loadDemo} onClear={clearAll} />
setEditingEdgeId(id)} /> {state.nodes.length === 0 && (
🃏

CARD COMBO

TCG Combo Mapper

Add cards, then use arrows to
visualize plays, summons, and effect chains.

)}
addNode()} clearAll={clearAll} onLoadDemo={loadDemo} createGroupFromNode={createGroupFromNode} updateGroup={updateGroup} deleteGroup={deleteGroup} removeNodeFromGroup={removeNodeFromGroup} composingGroupId={composingGroupId} setComposingGroupId={setComposingGroupId} /> {toast &&
{toast}
} {shotMode && ( <>
📸 Take a screenshot — {navigator.platform?.includes('Mac') ? <>++4 : <>Win+Shift+S}
)}
); } // ---------- demo data builder ---------- function buildDemoData(){ // Generic card-game combo demo with a clean left-to-right narrative: // hand (nA, nB) → field (nC, nD, nG) → finisher (nF) → target (nE). // Showcases every major feature so first-time users see what's possible: // - damage / buff effects (nE / nF) // - zone grouping (Hand / Field) // - two-way flow (nD ⇄ nF "Linked") // - cracked style (Disrupt) // - thick arrow (finishing attack) // - branch node on nC→nD arrow that forks the effect to nG (PC-only feature) // Positions are spread out more than the first draft so nothing crowds. const nodes = [ { id:'nA', name:'Starter', subtitle:'Opening hand / 1 card', emoji:'🃏', color:'#E9C46A', shape:'square', x:140, y:320, labelPos:'right' }, { id:'nB', name:'Searched card', subtitle:'Added to hand', emoji:'➲', color:'#7FE3A9', shape:'square', x:140, y:620 }, { id:'nC', name:'Combo piece', subtitle:'Special summoned', emoji:'✦', color:'#6DD3F0', shape:'square', x:500, y:320, labelPos:'right' }, { id:'nD', name:'Called by effect', subtitle:'Field / Link partner', emoji:'⚡', color:'#F4A261', shape:'square', x:880, y:200, labelPos:'right' }, // New: second effect target — fed by the branch node on nC→nD. { id:'nG', name:'Secondary target', subtitle:'Activates at the same time', emoji:'✺', color:'#FF6B9D', shape:'square', x:880, y:460, labelPos:'right' }, // Opponent's disruption card — damage effect showcases the burst aura. { id:'nE', name:"Opponent's disruption", subtitle:'Hand Trap', emoji:'🛡', color:'#C77DFF', shape:'square', x:880, y:720, effect:'damage', labelPos:'left' }, // Final ace, powered up — buff effect shows rising particles. { id:'nF', name:'Finisher', subtitle:'Ace monster', emoji:'🐉', color:'#FF4545', shape:'square', x:1240, y:320, effect:'buff' }, // Branch node attached to e3 (nC→nD). t=0.3 places it left of the arrow's // midpoint so it doesn't overlap the "Activate Effect" label which sits at t≈0.5. // Resolver recomputes x/y every render — stored values are only a fallback // if e3 gets deleted. { id:'nBr', variant:'branch', shape:'circle', color:'#E9C46A', name:'', subtitle:'Effect forks in two directions', attachedEdgeId:'e3', t:0.3, x:615, y:285, labelPos:'top' }, ]; const edges = [ { id:'e1', from:'nA', to:'nB', relation:'search', style:'oneway', color:'#7FE3A9', label:'Search', stamp:'➲' }, { id:'e2', from:'nB', to:'nC', relation:'special', style:'oneway', color:'#6DD3F0', label:'Special Summon', stamp:'✦' }, // Effect arrow — branch node (nBr) is pinned to this edge and forks to nG. { id:'e3', from:'nC', to:'nD', relation:'effect', style:'oneway', color:'#E9C46A', label:'Activate Effect', stamp:'⚡' }, // customOffset bends the branch→nG arc so its label sits in the upper-right // space, away from the other arrows converging near nG. { id:'e3b',from:'nBr', to:'nG', relation:'effect', style:'oneway', color:'#FF6B9D', label:'At the same time', stamp:'✺', customOffset:-70 }, { id:'e4', from:'nD', to:'nF', relation:'cost', style:'twoway', color:'#B39DDB', label:'Link partner', stamp:'◆' }, { id:'e5', from:'nE', to:'nC', relation:'destroy', style:'cracked', color:'#FF4545', label:'Play around', stamp:'⚠' }, { id:'e6', from:'nF', to:'nE', relation:'attack', style:'oneway', color:'#FF4545', label:'Attack', stamp:'⚔', thickness:'thick' }, ]; const groups = [ { id:'g1', name:'Hand', color:'#E9C46A', nodeIds:['nA','nB'], labelOffset:{ dx:0, dy:-70 } }, // Field / Board sits higher (nD at y=200) so pull the label closer to the // group blob to avoid collision with the canvas title banner. { id:'g2', name:'Field / Board', color:'#7FE3A9', nodeIds:['nC','nD','nG'], labelOffset:{ dx:0, dy:-20 } }, ]; const notes = [ { id:'note_1', nodeId:'nA', text:'Starts the combo solo.\nAs long as you draw this,\nyou\'re good to go.', offset:{ dx:-200, dy:20 }, }, { id:'note_2', nodeId:'nF', text:'Boosted attack + protection.\nHandles the final hit while\nplaying around disruption.', offset:{ dx:180, dy:-10 }, }, { id:'note_3', nodeId:'nBr', text:'Branch node lets one\neffect act in two directions\nat the same time.', // Moved to upper-left of the branch node — out of the bottom-right where // the fixed credit / link overlay sits on wide screens. offset:{ dx:-200, dy:-110 }, }, ]; return { nodes, edges, groups, notes }; } window.App = App; window.buildDemoData = buildDemoData;