Skip to content

Commit 8cf60da

Browse files
committed
advanced visualization
1 parent dc70592 commit 8cf60da

3 files changed

Lines changed: 394 additions & 76 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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

Comments
 (0)