|
| 1 | +function renderQuestFlow() { |
| 2 | + const nodesLayer = document.getElementById('nodes-layer'); |
| 3 | + const edgesLayer = document.getElementById('edges-layer'); |
| 4 | + nodesLayer.innerHTML = ''; edgesLayer.innerHTML = ''; |
| 5 | + |
| 6 | + // 1. Prepare Global Data for Sidebar |
| 7 | + window.nodeMap = new Map(nodesData.map(n => [n.id, n])); |
| 8 | + window.allEdges = []; |
| 9 | + |
| 10 | + // Populate allEdges from the entire graph for the sidebar to function correctly |
| 11 | + nodesData.forEach(node => { |
| 12 | + if (node.out_edges) { |
| 13 | + Object.entries(node.out_edges).forEach(([type, targets]) => { |
| 14 | + targets.forEach(t => { |
| 15 | + const targetId = typeof t === 'string' ? t : t.targetId; |
| 16 | + window.allEdges.push({ |
| 17 | + id: `edge-${node.id}-${targetId}-${type}`, |
| 18 | + source: node.id, |
| 19 | + target: targetId, |
| 20 | + type: type, |
| 21 | + quantity: t.quantity || 1 |
| 22 | + }); |
| 23 | + }); |
| 24 | + }); |
| 25 | + } |
| 26 | + }); |
| 27 | + |
| 28 | + // 2. Filter to Quests and (in Advanced) quest-unlocked Cadences + their Abilities |
| 29 | + const quests = nodesData.filter(n => n.type === 'Quest'); |
| 30 | + |
| 31 | + // Find cadences that are unlocked by quests |
| 32 | + const questUnlockedCadenceIds = new Set(); |
| 33 | + quests.forEach(q => { |
| 34 | + if (q.out_edges && q.out_edges.unlocks_cadence) { |
| 35 | + q.out_edges.unlocks_cadence.forEach(t => questUnlockedCadenceIds.add(t.targetId)); |
| 36 | + } |
| 37 | + }); |
| 38 | + |
| 39 | + const cadences = currentView === 'advanced' |
| 40 | + ? nodesData.filter(n => n.type === 'Cadence' && questUnlockedCadenceIds.has(n.id)) |
| 41 | + : []; |
| 42 | + |
| 43 | + // Find abilities provided by those cadences |
| 44 | + const cadenceProvidedAbilityIds = new Set(); |
| 45 | + cadences.forEach(c => { |
| 46 | + if (c.out_edges && c.out_edges.provides_ability) { |
| 47 | + c.out_edges.provides_ability.forEach(t => cadenceProvidedAbilityIds.add(t.targetId)); |
| 48 | + } |
| 49 | + }); |
| 50 | + |
| 51 | + const abilities = currentView === 'advanced' |
| 52 | + ? nodesData.filter(n => n.type === 'Ability' && cadenceProvidedAbilityIds.has(n.id)) |
| 53 | + : []; |
| 54 | + |
| 55 | + const flowEntities = [...quests, ...cadences, ...abilities]; |
| 56 | + const entityMap = new Map(flowEntities.map(n => [n.id, n])); |
| 57 | + |
| 58 | + // 3. Build Dependency Graph for Layout |
| 59 | + const adj = new Map(); |
| 60 | + const revAdj = new Map(); |
| 61 | + flowEntities.forEach(q => { |
| 62 | + adj.set(q.id, []); |
| 63 | + revAdj.set(q.id, []); |
| 64 | + }); |
| 65 | + |
| 66 | + flowEntities.forEach(q => { |
| 67 | + // Quest Dependencies |
| 68 | + if (q.in_edges && q.in_edges.requires_quest) { |
| 69 | + q.in_edges.requires_quest.forEach(reqId => { |
| 70 | + if (entityMap.has(reqId)) { |
| 71 | + adj.get(reqId).push(q.id); |
| 72 | + revAdj.get(q.id).push(reqId); |
| 73 | + } |
| 74 | + }); |
| 75 | + } |
| 76 | + // Cadence Unlocks (Quest -> Cadence) |
| 77 | + if (q.type === 'Cadence') { |
| 78 | + // Find which quests unlock this cadence |
| 79 | + quests.forEach(otherQ => { |
| 80 | + if (otherQ.out_edges && otherQ.out_edges.unlocks_cadence) { |
| 81 | + if (otherQ.out_edges.unlocks_cadence.some(t => t.targetId === q.id)) { |
| 82 | + adj.get(otherQ.id).push(q.id); |
| 83 | + revAdj.get(q.id).push(otherQ.id); |
| 84 | + } |
| 85 | + } |
| 86 | + }); |
| 87 | + } |
| 88 | + // Ability Unlocks (Cadence -> Ability) |
| 89 | + if (q.type === 'Ability') { |
| 90 | + // Find which cadences provide this ability |
| 91 | + cadences.forEach(otherC => { |
| 92 | + if (otherC.out_edges && otherC.out_edges.provides_ability) { |
| 93 | + if (otherC.out_edges.provides_ability.some(t => t.targetId === q.id)) { |
| 94 | + adj.get(otherC.id).push(q.id); |
| 95 | + revAdj.get(q.id).push(otherC.id); |
| 96 | + } |
| 97 | + } |
| 98 | + }); |
| 99 | + } |
| 100 | + }); |
| 101 | + |
| 102 | + // 3. Calculate Tiers |
| 103 | + const entityTiers = new Map(); |
| 104 | + const roots = flowEntities.filter(q => revAdj.get(q.id).length === 0); |
| 105 | + const queue = roots.map(r => ({ id: r.id, depth: 0 })); |
| 106 | + |
| 107 | + roots.forEach(r => entityTiers.set(r.id, 0)); |
| 108 | + |
| 109 | + while (queue.length > 0) { |
| 110 | + const { id, depth } = queue.shift(); |
| 111 | + adj.get(id).forEach(neighborId => { |
| 112 | + const currentTier = entityTiers.get(neighborId) || 0; |
| 113 | + if (depth + 1 > currentTier) { |
| 114 | + entityTiers.set(neighborId, depth + 1); |
| 115 | + queue.push({ id: neighborId, depth: depth + 1 }); |
| 116 | + } |
| 117 | + }); |
| 118 | + } |
| 119 | + |
| 120 | + // 4. Group by Tiers and Order |
| 121 | + const FLOW_TIER_WIDTH = 700; // Increased for more breathing room |
| 122 | + const FLOW_VERTICAL_SPACING = 160; |
| 123 | + const tierGroups = []; |
| 124 | + |
| 125 | + flowEntities.forEach(q => { |
| 126 | + const t = entityTiers.get(q.id) || 0; |
| 127 | + if (!tierGroups[t]) tierGroups[t] = []; |
| 128 | + tierGroups[t].push(q); |
| 129 | + }); |
| 130 | + |
| 131 | + const flowNodes = []; |
| 132 | + const flowEdges = []; |
| 133 | + const nodeYPositions = new Map(); |
| 134 | + |
| 135 | + tierGroups.forEach((tierEntities, t) => { |
| 136 | + if (t > 0) { |
| 137 | + tierEntities.sort((a, b) => { |
| 138 | + const parentsA = revAdj.get(a.id); |
| 139 | + const parentsB = revAdj.get(b.id); |
| 140 | + const getAvgY = (parents) => { |
| 141 | + if (parents.length === 0) return 0; |
| 142 | + let sum = 0; |
| 143 | + parents.forEach(pId => sum += nodeYPositions.get(pId) || 0); |
| 144 | + return sum / parents.length; |
| 145 | + }; |
| 146 | + return getAvgY(parentsA) - getAvgY(parentsB); |
| 147 | + }); |
| 148 | + } else { |
| 149 | + tierEntities.sort((a, b) => a.name.localeCompare(b.name)); |
| 150 | + } |
| 151 | + |
| 152 | + tierEntities.forEach((q, idx) => { |
| 153 | + const fy = idx * FLOW_VERTICAL_SPACING; |
| 154 | + nodeYPositions.set(q.id, idx); |
| 155 | + flowNodes.push({ ...q, fx: t * FLOW_TIER_WIDTH, fy: fy, isTerminal: false }); |
| 156 | + }); |
| 157 | + }); |
| 158 | + |
| 159 | + // 5. Edges and Terminals |
| 160 | + flowNodes.forEach(qNode => { |
| 161 | + adj.get(qNode.id).forEach(targetId => { |
| 162 | + flowEdges.push({ id: `flow-${qNode.id}-${targetId}`, source: qNode.id, target: targetId, category: 'progression' }); |
| 163 | + }); |
| 164 | + |
| 165 | + if (currentView === 'advanced' && qNode.type === 'Quest' && qNode.data.quest_type === 'Recurring' && qNode.out_edges && qNode.out_edges.rewards) { |
| 166 | + const rewardCount = qNode.out_edges.rewards.length; |
| 167 | + qNode.out_edges.rewards.forEach((rew, idx) => { |
| 168 | + const itemData = nodesData.find(n => n.id === rew.targetId); |
| 169 | + if (itemData) { |
| 170 | + const rate = (rew.quantity * 60) / (qNode.data.duration || 10); |
| 171 | + const terminalId = `terminal-${qNode.id}-${itemData.id}`; |
| 172 | + |
| 173 | + // Offset rewards vertically so they don't overlap, centering them on the quest |
| 174 | + // Using a slightly smaller multiplier to keep them tight to the parent |
| 175 | + const verticalOffset = (idx - (rewardCount - 1) / 2) * 40; |
| 176 | + |
| 177 | + flowNodes.push({ |
| 178 | + ...itemData, |
| 179 | + id: terminalId, |
| 180 | + name: `${itemData.name} (${rate.toFixed(1)}/m)`, |
| 181 | + fx: qNode.fx + 300, // Positioned in the middle of the tier gap |
| 182 | + fy: qNode.fy + verticalOffset, |
| 183 | + isTerminal: true |
| 184 | + }); |
| 185 | + flowEdges.push({ id: `flow-reward-${qNode.id}-${terminalId}`, source: qNode.id, target: terminalId, category: 'economy' }); |
| 186 | + } |
| 187 | + }); |
| 188 | + } |
| 189 | + }); |
| 190 | + |
| 191 | + // 6. Final Render with Lane Assignment |
| 192 | + const flowNodeIdMap = new Map(flowNodes.map(n => [n.id, n])); |
| 193 | + |
| 194 | + // Group edges by their X-span to assign vertical lanes |
| 195 | + const spanGroups = new Map(); |
| 196 | + flowEdges.forEach(edge => { |
| 197 | + const s = flowNodeIdMap.get(edge.source); |
| 198 | + const t = flowNodeIdMap.get(edge.target); |
| 199 | + if (s && t) { |
| 200 | + const key = `${s.fx}-${t.fx}`; |
| 201 | + if (!spanGroups.has(key)) spanGroups.set(key, []); |
| 202 | + spanGroups.get(key).push(edge); |
| 203 | + } |
| 204 | + }); |
| 205 | + |
| 206 | + spanGroups.forEach((edgesInSpan, key) => { |
| 207 | + // Sort edges in span by their vertical midpoint to keep lanes somewhat orderly |
| 208 | + edgesInSpan.sort((a, b) => { |
| 209 | + const sa = flowNodeIdMap.get(a.source); |
| 210 | + const ta = flowNodeIdMap.get(a.target); |
| 211 | + const sb = flowNodeIdMap.get(b.source); |
| 212 | + const tb = flowNodeIdMap.get(b.target); |
| 213 | + return (sa.fy + ta.fy) - (sb.fy + tb.fy); |
| 214 | + }); |
| 215 | + |
| 216 | + const [startFx, endFx] = key.split('-').map(Number); |
| 217 | + const laneCount = edgesInSpan.length; |
| 218 | + const spanWidth = endFx - startFx; |
| 219 | + |
| 220 | + // Distribution logic: Vertical segments are placed in a 'corridor' at the center of the span |
| 221 | + const corridorWidth = Math.min(spanWidth * 0.6, laneCount * 20); |
| 222 | + const laneStep = laneCount > 1 ? corridorWidth / (laneCount - 1) : 0; |
| 223 | + const startLaneX = startFx + (spanWidth - corridorWidth) / 2; |
| 224 | + |
| 225 | + edgesInSpan.forEach((edge, idx) => { |
| 226 | + const s = flowNodeIdMap.get(edge.source); |
| 227 | + const t = flowNodeIdMap.get(edge.target); |
| 228 | + |
| 229 | + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| 230 | + path.setAttribute('class', `edge ${edge.category}`); |
| 231 | + path.setAttribute('id', edge.id); |
| 232 | + |
| 233 | + // Lane X position |
| 234 | + const midX = laneCount > 1 ? startLaneX + (idx * laneStep) : startFx + spanWidth * 0.5; |
| 235 | + |
| 236 | + // Orthogonal path: Horizontal -> Vertical -> Horizontal |
| 237 | + const d = `M ${s.fx} ${s.fy} L ${midX} ${s.fy} L ${midX} ${t.fy} L ${t.fx} ${t.fy}`; |
| 238 | + path.setAttribute('d', d); |
| 239 | + edgesLayer.appendChild(path); |
| 240 | + }); |
| 241 | + }); |
| 242 | + |
| 243 | + flowNodes.forEach(node => { |
| 244 | + const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); |
| 245 | + g.setAttribute('class', `node ${node.is_milestone ? 'milestone' : ''}`); |
| 246 | + g.setAttribute('transform', `translate(${node.fx}, ${node.fy})`); |
| 247 | + g.setAttribute('id', `node-${node.id}`); |
| 248 | + |
| 249 | + let shape; |
| 250 | + if (node.isTerminal) { |
| 251 | + shape = document.createElementNS("http://www.w3.org/2000/svg", "circle"); |
| 252 | + shape.setAttribute('r', '8'); |
| 253 | + shape.setAttribute('fill', 'var(--item-color)'); |
| 254 | + } else { |
| 255 | + if (node.type === 'Cadence') { |
| 256 | + shape = document.createElementNS("http://www.w3.org/2000/svg", "rect"); |
| 257 | + shape.setAttribute('x', '-14'); shape.setAttribute('y', '-14'); |
| 258 | + shape.setAttribute('width', '28'); shape.setAttribute('height', '28'); |
| 259 | + shape.setAttribute('rx', '4'); |
| 260 | + shape.setAttribute('fill', 'var(--cadence-color)'); |
| 261 | + } else if (node.type === 'Ability') { |
| 262 | + shape = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); |
| 263 | + shape.setAttribute('points', '0,-14 14,0 0,14 -14,0'); |
| 264 | + shape.setAttribute('fill', 'var(--ability-color)'); |
| 265 | + } else { |
| 266 | + shape = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); |
| 267 | + shape.setAttribute('points', '-14,-7 -14,7 0,14 14,7 14,-7 0,-14'); |
| 268 | + shape.setAttribute('fill', 'var(--quest-color)'); |
| 269 | + } |
| 270 | + } |
| 271 | + g.appendChild(shape); |
| 272 | + |
| 273 | + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); |
| 274 | + text.setAttribute('class', 'label'); |
| 275 | + text.setAttribute('y', node.isTerminal ? '5' : '28'); |
| 276 | + text.setAttribute('x', node.isTerminal ? '15' : '0'); |
| 277 | + text.setAttribute('text-anchor', node.isTerminal ? 'start' : 'middle'); |
| 278 | + text.style.fontSize = node.isTerminal ? '10px' : '12px'; |
| 279 | + text.textContent = node.name; |
| 280 | + g.appendChild(text); |
| 281 | + |
| 282 | + g.addEventListener('mouseenter', (e) => showTooltip(e, node)); |
| 283 | + g.addEventListener('mouseleave', hideTooltip); |
| 284 | + g.addEventListener('click', () => selectNode(node)); |
| 285 | + nodesLayer.appendChild(g); |
| 286 | + }); |
| 287 | +} |
0 commit comments