// The technology tree's geometry and pure helpers. // // Every technology shares one graph: a technology's column is its distance from // a root (the longest chain of prerequisites behind it), and each column holds // every technology of that tier. // // Links are routed orthogonally so that no two runs ever lie on top of each // other: each edge leaves its parent's right edge at its own port, travels down // a vertical lane unique to its gutter, crosses a horizontal track unique to its // row gap, then enters the child's left edge at its own port. That costs some // width (the gutters widen with the number of lanes) but keeps every dependency // readable even when a node has many parents or children. // Each research theme borrows the building-card icon of its field, so a // technology card reads with the same visual grammar as a building. The colour // tints the card's left edge and keys the legend above the tree. export const TECH_THEMES = { science: { name: "Science", color: "#5aa9ff", icon: "icon_university.svg" }, economy: { name: "Economy", color: "#e0b050", icon: "icon_bank.svg" }, society: { name: "Society", color: "#9a7bff", icon: "icon_museum.svg" }, military: { name: "Military", color: "#ff6b5e", icon: "icon_fortifications.svg" }, }; export const TECH_THEME_ORDER = ["science", "economy", "society", "military"]; export function techTheme(id) { return TECH_THEMES[id] || TECH_THEMES.science; } // Repeatable technologies carry a Roman-numeral level up to X; beyond that a // plain number is used (exponential costs make it unreachable in practice). const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]; export function romanNumeral(n) { return ROMAN[n] || String(n); } export const TECH_LAYOUT = { W: 196, H: 76, GX: 36, GY: 18, PADX: 16, PADY: 20, LANE: 12, LANE_MARGIN: 12, TRACK: 12, TRACK_MARGIN: 9, }; // A technology's tier: one past the deepest prerequisite. function depthOf(index, technologies, byId, cache) { if (cache.has(index)) return cache.get(index); let depth = 0; for (const id of technologies[index].prerequisites || []) { const parent = byId.get(id); if (parent !== undefined) depth = Math.max(depth, 1 + depthOf(parent, technologies, byId, cache)); } cache.set(index, depth); return depth; } // Positions every card and routes every prerequisite link. The returned // `placed` map gives each technology's column and pixel box; `edges` carries the // link's polyline points (ready for `chamferPath`) and the dot where it meets // the child. export function layoutTechnologies(technologies) { const { W, H, GX, GY: BASE_GY, PADX, PADY, LANE, LANE_MARGIN, TRACK, TRACK_MARGIN } = TECH_LAYOUT; const byId = new Map(technologies.map((t, i) => [t.id, i])); const cache = new Map(); const byDepth = new Map(); technologies.forEach((t, index) => { const d = depthOf(index, technologies, byId, cache); if (!byDepth.has(d)) byDepth.set(d, []); byDepth.get(d).push(index); }); // Rows within a column: order children by where their parents sit, so links // cross as little as possible, then by name for a stable layout. const rowOf = new Map(); const colRow = new Map(); const depths = [...byDepth.keys()].sort((a, b) => a - b); for (const d of depths) { const group = byDepth.get(d); group.sort((a, b) => { const key = (index) => { const rows = (technologies[index].prerequisites || []) .map((id) => rowOf.get(byId.get(id))) .filter((r) => r !== undefined); return rows.length ? rows.reduce((x, y) => x + y, 0) / rows.length : 0; }; return key(a) - key(b) || technologies[a].name.localeCompare(technologies[b].name); }); group.forEach((index, row) => { rowOf.set(index, row); colRow.set(index, { col: d, row }); }); } const numCols = depths.length ? depths[depths.length - 1] + 1 : 0; let maxRow = 0; for (const pos of colRow.values()) maxRow = Math.max(maxRow, pos.row); // Every prerequisite link, with the columns and rows at each end. const edges = []; technologies.forEach((tech, to) => { for (const id of tech.prerequisites || []) { const from = byId.get(id); if (from === undefined || !colRow.has(from) || !colRow.has(to)) continue; const a = colRow.get(from); const b = colRow.get(to); edges.push({ from, to, fromCol: a.col, fromRow: a.row, toCol: b.col, toRow: b.row, span: b.col - a.col }); } }); edges.sort( (a, b) => a.fromCol - b.fromCol || a.fromRow - b.fromRow || a.toCol - b.toCol || a.toRow - b.toRow ); // Vertical lanes live in the gutter between two columns: links leaving a // column near its left side, links arriving at the next column near its right // side. Wide enough gutters keep those lanes apart. const numGutters = Math.max(0, numCols - 1); const parentRuns = Array.from({ length: numGutters }, () => []); const childRuns = Array.from({ length: numGutters }, () => []); for (const e of edges) { if (e.span > 1) parentRuns[e.fromCol].push(e); if (e.toCol >= 1) childRuns[e.toCol - 1].push(e); } const gutterWidth = []; for (let g = 0; g < numGutters; g++) { parentRuns[g].sort((a, b) => a.fromRow - b.fromRow || a.toCol - b.toCol || a.toRow - b.toRow); // Child runs are ordered so the lane closest to the child belongs to the // link whose port sits lowest. That keeps siblings from criss-crossing as // their stubs leave one parent. childRuns[g].sort((a, b) => b.toRow - a.toRow || a.fromCol - b.fromCol || a.fromRow - b.fromRow); const lanes = parentRuns[g].length + childRuns[g].length; gutterWidth[g] = lanes ? Math.max(GX, 2 * LANE_MARGIN + lanes * LANE) : GX; } const colX = []; let x = PADX; for (let c = 0; c < numCols; c++) { colX[c] = x; x += W + (gutterWidth[c] || 0); } const width = x + PADX; // Horizontal tracks: one per spanning link, spread across the row gap just // below its parent. The gap grows when several links fan out from one row. const gapTracks = new Map(); for (const e of edges) { if (e.span <= 1) continue; const k = e.fromRow + 1; gapTracks.set(k, (gapTracks.get(k) || 0) + 1); } const maxTracks = Math.max(1, ...gapTracks.values()); const GY = Math.max(BASE_GY, (maxTracks - 1) * TRACK + 2 * TRACK_MARGIN); const rowY = (row) => PADY + row * (H + GY); const gapCenter = (k) => PADY + k * (H + GY) - GY / 2; const trackSeen = new Map(); for (const e of edges) { if (e.span <= 1) continue; const k = e.fromRow + 1; const n = gapTracks.get(k); const i = trackSeen.get(k) || 0; trackSeen.set(k, i + 1); e.trackY = n === 1 ? gapCenter(k) : gapCenter(k) + (i - (n - 1) / 2) * TRACK; } // Attach a lane x to each vertical run. for (let g = 0; g < numGutters; g++) { const left = colX[g] + W; const right = colX[g + 1]; parentRuns[g].forEach((e, i) => { e.parentLaneX = left + LANE_MARGIN + (i + 0.5) * LANE; }); childRuns[g].forEach((e, i) => { e.childLaneX = right - LANE_MARGIN - (i + 0.5) * LANE; }); } // A card has a single port each way: every dependency leaves the middle of // its right edge and every dependency arrives at the middle of its left edge, // so a fan of links spreads from one point rather than a row of stubs. const midY = (row) => rowY(row) + H / 2; for (const e of edges) { e.outY = midY(e.fromRow); e.inY = midY(e.toRow); } const placed = new Map(); for (const [index, pos] of colRow) { placed.set(index, { col: pos.col, row: pos.row, x: colX[pos.col], y: rowY(pos.row) }); } // Out of the parent's port, along its lane, across a track (only when the // child is more than one column away), down the child's lane, into its port. for (const e of edges) { const parent = placed.get(e.from); const child = placed.get(e.to); const start = parent.x + W; const end = child.x; if (e.span <= 1) { e.points = [[start, e.outY], [e.childLaneX, e.outY], [e.childLaneX, e.inY], [end, e.inY]]; } else { e.points = [ [start, e.outY], [e.parentLaneX, e.outY], [e.parentLaneX, e.trackY], [e.childLaneX, e.trackY], [e.childLaneX, e.inY], [end, e.inY], ]; } e.dot = { x: end, y: e.inY }; } const height = PADY * 2 + (maxRow + 1) * H + maxRow * GY; return { nodes: technologies.map((_, index) => ({ index })), byId, placed, edges, maxRow, width, height, }; } // A path of axis-aligned runs with each 90-degree corner cut by a short // 45-degree chamfer. export function chamferPath(points, size = 7) { const pts = points.filter( (p, i, a) => i === 0 || Math.hypot(p[0] - a[i - 1][0], p[1] - a[i - 1][1]) > 0.5 ); if (pts.length < 2) return ""; let d = `M ${pts[0][0]} ${pts[0][1]}`; for (let i = 1; i < pts.length - 1; i++) { const p = pts[i], prev = pts[i - 1], next = pts[i + 1]; const cross = (p[0] - prev[0]) * (next[1] - p[1]) - (p[1] - prev[1]) * (next[0] - p[0]); if (Math.abs(cross) < 0.001) { d += ` L ${p[0]} ${p[1]}`; continue; } const l1 = Math.hypot(p[0] - prev[0], p[1] - prev[1]); const l2 = Math.hypot(next[0] - p[0], next[1] - p[1]); const c = Math.min(size, l1 / 2, l2 / 2); const u1 = [(prev[0] - p[0]) / l1, (prev[1] - p[1]) / l1]; const u2 = [(next[0] - p[0]) / l2, (next[1] - p[1]) / l2]; d += ` L ${p[0] + u1[0] * c} ${p[1] + u1[1] * c} L ${p[0] + u2[0] * c} ${p[1] + u2[1] * c}`; } const last = pts[pts.length - 1]; return `${d} L ${last[0]} ${last[1]}`; } // Every prerequisite still outstanding for the technology at `index`, at any // depth: the trace of what stands between the nation and this research. A // prerequisite gated behind a missing building counts as outstanding too. export function unmetPrerequisites(index, technologies, researched, hasBuilding) { const byId = new Map(technologies.map((t, i) => [t.id, i])); const unmet = new Set(); const walk = (at) => { for (const id of technologies[at].prerequisites || []) { const parent = byId.get(id); if (parent === undefined) continue; const proto = technologies[parent]; const buildingMissing = !!proto.requiresBuilding && !hasBuilding(proto.requiresBuilding); if (!researched.has(parent) || buildingMissing) { if (!unmet.has(parent)) { unmet.add(parent); walk(parent); } } } }; walk(index); return unmet; }