diff --git a/apps/web/app/analyze/[id]/page.tsx b/apps/web/app/analyze/[id]/page.tsx index 77b032d..5528569 100644 --- a/apps/web/app/analyze/[id]/page.tsx +++ b/apps/web/app/analyze/[id]/page.tsx @@ -6,7 +6,7 @@ import ForceGraph from "@/components/ForceGraph"; import HistorySidebar from "@/components/HistorySidebar"; import RightRail from "@/components/RightRail"; import { getAnalysis, simulate } from "@/lib/api"; -import type { JobRecord, SimulateResponse } from "@/lib/types"; +import type { JobRecord, SimulateResponse, WhatIfState } from "@/lib/types"; export default function AnalysisPage({ params }: { params: { id: string } }) { const [job, setJob] = useState(null); @@ -16,6 +16,7 @@ export default function AnalysisPage({ params }: { params: { id: string } }) { const [sim, setSim] = useState(null); const [round, setRound] = useState(0); const [playing, setPlaying] = useState(false); + const [whatIf, setWhatIf] = useState(null); useEffect(() => { let alive = true; @@ -102,7 +103,14 @@ export default function AnalysisPage({ params }: { params: { id: string } }) {
diff --git a/apps/web/components/ForceGraph.tsx b/apps/web/components/ForceGraph.tsx index 85e69e8..2b49bfd 100644 --- a/apps/web/components/ForceGraph.tsx +++ b/apps/web/components/ForceGraph.tsx @@ -2,17 +2,137 @@ import dynamic from "next/dynamic"; import { useEffect, useMemo, useRef, useState } from "react"; -import { disruptionColor, edgeColor, impactColor } from "@/lib/color"; -import type { AnalysisResult } from "@/lib/types"; +import { edgeColor } from "@/lib/color"; +import type { AnalysisResult, Node as GNode } from "@/lib/types"; const ForceGraph2D = dynamic(() => import("react-force-graph-2d"), { ssr: false }); -// Layout safety: nodeRelSize=6 with val up to ~14 → max radius ≈ 22px (diameter ≈ 45px). -// Keep generous gaps so a fat node + a small node don't visually kiss. -const MIN_X_SPACING = 220; -const MIN_Y_SPACING = 110; -const PAD_X = 110; -const PAD_Y = 90; +// Layout padding (world units around the layout bbox). +const PAD_X = 90; +const PAD_Y = 70; + +// All tiles are the same size on screen — n_reachable is shown in the +// right-rail Ranking table, no need to encode it again as ball size. +const TILE_PX = 52; +const EMOJI_PX = 36; // emoji rendered at this size (slightly < tile) +const BAR_WIDTH_PX = 48; +const BAR_HEIGHT_PX = 7; +const BAR_GAP_ABOVE_PX = 5; // gap between tile and bar +const LABEL_GAP_PX = 4; // gap between bar and label +const BAR_SEGMENTS = 5; +const BAR_SEG_GAP_PX = 2; + +// Sector → emoji. Keys are matched case-insensitively against `node.sector`. +const SECTOR_EMOJI: Record = { + focal: "🏢", + assembly: "🏭", + components: "⚙️", + electronics: "🖥️", + semiconductor: "💾", + chip: "🖥️", + raw_materials: "⛏️", + mining: "⛏️", + metals: "🪨", + battery: "🔋", + energy: "⚡", + display: "📺", + sensor: "📡", + packaging: "📦", + logistics: "🚚", + shipping: "🚢", + software: "💻", + services: "🛠️", + finance: "💳", + pharma: "💊", + food: "🍎", + agriculture: "🌾", + textiles: "🧵", + chemicals: "🧪", +}; + +// Keyword → emoji fallback when the sector field is missing or unrecognized. +const LABEL_KEYWORD_EMOJI: Array<[RegExp, string]> = [ + [/\b(mine|mining|miner)\b/i, "⛏️"], + [/\b(refiner|refinery|smelter)\b/i, "🧪"], + [/\b(cobalt|nickel|copper|rare\s*earth)/i, "🪨"], + [/\b(lithium)\b/i, "🧪"], + [/\b(battery|cell)\b/i, "🔋"], + [/\b(wafer|fab|foundry|semiconductor)\b/i, "💾"], + [/\b(chip(set)?|cpu|gpu|soc|processor)\b/i, "🖥️"], + [/\b(display|panel|screen|oled|lcd)\b/i, "📺"], + [/\b(sensor|camera|lens)\b/i, "📡"], + [/\b(assembly|integrator|assembler)\b/i, "🏭"], + [/\b(packaging|carton)\b/i, "📦"], + [/\b(logistics|freight|shipping|carrier)\b/i, "🚚"], + [/\b(software|cloud|saas)\b/i, "💻"], + [/\b(phone|smartphone|handset|iphone)\b/i, "📱"], + [/\b(apple|google|samsung|tesla|pfizer|microsoft)\b/i, "🏢"], +]; + +function emojiFor(node: GNode): string { + const sector = (node.sector ?? "").toLowerCase().trim(); + if (sector && SECTOR_EMOJI[sector]) return SECTOR_EMOJI[sector]; + for (const [re, emoji] of LABEL_KEYWORD_EMOJI) { + if (re.test(node.label) || re.test(node.id)) return emoji; + } + return "🏢"; +} + +// Map impact percentile to (filled segment count, fill color). Pure sinks +// (no impact, percentile=null) get 0 filled segments — empty bar. +function energyTier(p: number | null | undefined): { filled: number; color: string } { + if (p == null) return { filled: 0, color: "#374151" }; + if (p >= 80) return { filled: 5, color: "#ef4444" }; // red + if (p >= 60) return { filled: 4, color: "#f97316" }; // orange + if (p >= 40) return { filled: 3, color: "#eab308" }; // yellow + if (p >= 20) return { filled: 2, color: "#84cc16" }; // lime + return { filled: 1, color: "#22c55e" }; // green +} + +// Map edge weight (0..1) to capsule background color — same palette as +// the energy bar so the two encodings feel related. Heavy dependency = red. +function weightCapsuleColor(w: number): string { + if (w >= 0.6) return "#ef4444"; + if (w >= 0.4) return "#f97316"; + if (w >= 0.25) return "#eab308"; + if (w >= 0.1) return "#84cc16"; + return "#22c55e"; +} + +// Footprint animation tuning. +const FOOTPRINT_COUNT = 4; +const FOOTPRINT_PERIOD_MS = 2400; +const FOOTPRINT_PX = 11; +const FOOTPRINT_COLOR = "#22c55e"; // green +const FOOTPRINT_SIDE_OFFSET_PX = 5; // perpendicular L/R offset for walking gait + +// Draw a small stylized footprint at origin, oriented "toes up" along +y axis +// in local space. The caller is responsible for translate/rotate before calling. +function drawFootprint(ctx: CanvasRenderingContext2D, sizePx: number, color: string) { + const s = sizePx; + ctx.fillStyle = color; + + // Ball of foot — main oval just above center. + ctx.beginPath(); + ctx.ellipse(0, -s * 0.05, s * 0.22, s * 0.30, 0, 0, 2 * Math.PI); + ctx.fill(); + + // Heel — smaller round oval below. + ctx.beginPath(); + ctx.ellipse(0, s * 0.34, s * 0.17, s * 0.18, 0, 0, 2 * Math.PI); + ctx.fill(); + + // 4 toes — small circles arched in front, big toe inside. + const toeY = -s * 0.40; + for (let i = 0; i < 4; i++) { + const tx = (i - 1.5) * s * 0.11; + const ty = toeY + Math.abs(i - 1.5) * s * 0.025; + const tr = s * 0.075 * (1 - Math.abs(i - 1.5) * 0.1); + ctx.beginPath(); + ctx.arc(tx, ty, tr, 0, 2 * Math.PI); + ctx.fill(); + } +} export default function ForceGraph({ result, @@ -23,8 +143,8 @@ export default function ForceGraph({ onSelect?: (nodeId: string) => void; disruption?: Record | null; }) { - // Size the canvas to its container (the lib otherwise defaults to the full window). const wrapRef = useRef(null); + const fgRef = useRef(null); const [size, setSize] = useState({ w: 0, h: 0 }); useEffect(() => { const el = wrapRef.current; @@ -41,9 +161,7 @@ export default function ForceGraph({ if (size.w === 0 || size.h === 0) return { nodes: [], links: [] }; const byId = new Map(result.node_analyses.map((a) => [a.node_id, a])); - // Resolve a tier for every node: prefer the schema field, else BFS from focal - // along reversed edges (supplier → customer reversed = customer → supplier), - // so depth-from-focal becomes tier. + // Resolve a tier for every node: prefer the schema field, else BFS from focal. const tierOf = new Map(); for (const n of result.graph.nodes) { if (typeof n.tier === "number") tierOf.set(n.id, n.tier); @@ -73,34 +191,69 @@ export default function ForceGraph({ } } - // Group node ids by tier so we can spread them vertically. const byTier = new Map(); for (const [id, t] of tierOf) { if (!byTier.has(t)) byTier.set(t, []); byTier.get(t)!.push(id); } - for (const arr of byTier.values()) arr.sort(); // stable within a tier + for (const arr of byTier.values()) arr.sort(); const tierIndices = Array.from(byTier.keys()); const minTier = Math.min(...tierIndices); const maxTier = Math.max(...tierIndices); const numTiers = maxTier - minTier + 1; + + // Barycenter sweep (Sugiyama heuristic): for each tier, reorder nodes by the + // average index of their neighbors in adjacent tiers. A few passes converge + // on a layout that minimizes edge crossings between layers. + const undirectedNbrs = new Map(); + for (const e of result.graph.edges) { + if (!undirectedNbrs.has(e.source)) undirectedNbrs.set(e.source, []); + if (!undirectedNbrs.has(e.target)) undirectedNbrs.set(e.target, []); + undirectedNbrs.get(e.source)!.push(e.target); + undirectedNbrs.get(e.target)!.push(e.source); + } + const ITER = 8; + const sweepOnce = (start: number, end: number, step: number) => { + for (let t = start; step > 0 ? t <= end : t >= end; t += step) { + const arr = byTier.get(t); + if (!arr || arr.length <= 1) continue; + const bc = new Map(); + for (const id of arr) { + const xs: number[] = []; + for (const nbr of undirectedNbrs.get(id) ?? []) { + const nbrT = tierOf.get(nbr); + if (nbrT == null || Math.abs(nbrT - t) !== 1) continue; + const nbrArr = byTier.get(nbrT)!; + const idx = nbrArr.indexOf(nbr); + if (idx >= 0) xs.push(idx); + } + bc.set(id, xs.length > 0 ? xs.reduce((a, b) => a + b, 0) / xs.length : Number.POSITIVE_INFINITY); + } + arr.sort((a, b) => { + const av = bc.get(a) ?? Number.POSITIVE_INFINITY; + const bv = bc.get(b) ?? Number.POSITIVE_INFINITY; + if (av === bv) return a.localeCompare(b); + return av - bv; + }); + } + }; + for (let pass = 0; pass < ITER; pass++) { + // Alternate sweep direction for faster convergence. + sweepOnce(minTier, maxTier, 1); + sweepOnce(maxTier, minTier, -1); + } + const maxTierPop = Math.max( ...Array.from(byTier.values()).map((arr) => arr.length), ); - // Lay out in canvas-pixel coordinates so the bounding box matches the canvas - // aspect ratio — this prevents react-force-graph's auto-zoom-to-fit from - // squishing the pyramid into a narrow band. Also enforce a minimum spacing - // so balls never overlap even on a tall/narrow window. - const usableW = size.w - 2 * PAD_X; - const usableH = size.h - 2 * PAD_Y; - const xSpacing = numTiers > 1 - ? Math.max(MIN_X_SPACING, usableW / (numTiers - 1)) - : 0; - const ySpacing = maxTierPop > 1 - ? Math.max(MIN_Y_SPACING, usableH / (maxTierPop - 1)) - : 0; + // Lay out so the layout bbox exactly matches the available canvas area — + // no minimum spacing, no zoom dance, fits on first paint. + const usableW = Math.max(0, size.w - 2 * PAD_X); + const usableH = Math.max(0, size.h - 2 * PAD_Y); + const xSpacing = numTiers > 1 ? usableW / (numTiers - 1) : 0; + const ySpacing = maxTierPop > 1 ? usableH / (maxTierPop - 1) : 0; const xCenterOffset = ((numTiers - 1) * xSpacing) / 2; const nodes = result.graph.nodes.map((n) => { @@ -110,21 +263,19 @@ export default function ForceGraph({ const idx = tierNodes.indexOf(n.id); const count = tierNodes.length; - // Focal (tier 0) sits on the right; deeper tiers extend left. const fx = (maxTier - t) * xSpacing - xCenterOffset; const fy = (idx - (count - 1) / 2) * ySpacing; - const color = disruption - ? disruptionColor(disruption[n.id] ?? 0) - : impactColor(a?.percentile ?? null); return { id: n.id, label: n.label, - color, - val: Math.max(1, (a?.n_reachable ?? 0) + 1), + // val held constant so click hit-area matches the uniform tile size. + val: 16, ept: a?.ept, impact: a?.impact_score, + percentile: a?.percentile ?? null, isFocal: n.id === result.graph.focal_node, + emoji: emojiFor(n), fx, fy, }; @@ -136,39 +287,91 @@ export default function ForceGraph({ color: edgeColor(e.confidence), })); return { nodes, links }; - }, [result, disruption, size]); + }, [result, size]); + + // Safety net: even though the layout is already sized to fit the canvas, + // ask the lib to zoom-to-fit after data lands. Covers any browser quirk + // around initial render where world coords might extend past the viewport. + useEffect(() => { + if (!data.nodes.length) return; + const id = window.setTimeout(() => { + fgRef.current?.zoomToFit?.(0, 40); + }, 50); + return () => window.clearTimeout(id); + }, [data]); return (
{size.w > 0 && ( n.color} nodeVal={(n: any) => n.val} nodeLabel={(n: any) => `${n.label}\nEPT: ${n.ept?.toFixed?.(2) ?? "—"} | impact: ${n.impact?.toFixed?.(2) ?? "—"}` } nodeRelSize={6} - nodeCanvasObjectMode={() => "after"} + nodeCanvasObjectMode={() => "replace"} nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const isFocal = node.isFocal as boolean; - const screenRadius = Math.sqrt(Math.max(0, node.val || 1)) * 6; - // Focal: draw a 2px white ring just outside the default circle so it - // pops as "this is the company we're analyzing". - if (isFocal) { + // All sizes divided by globalScale → constant on screen at any zoom level. + const tile = TILE_PX / globalScale; + const half = tile / 2; + const cornerR = 6 / globalScale; + + // Disruption red glow during cascade — behind everything else. + if (disruption) { + const p = disruption[node.id] ?? 0; + if (p > 0.01) { + ctx.beginPath(); + ctx.arc(node.x, node.y, half * 1.7, 0, 2 * Math.PI); + ctx.fillStyle = `rgba(239, 68, 68, ${Math.min(0.85, p * 0.85)})`; + ctx.fill(); + } + } + + // Tile background. + ctx.beginPath(); + ctx.roundRect(node.x - half, node.y - half, tile, tile, cornerR); + ctx.fillStyle = "#1f2937"; + ctx.fill(); + // Tile border — white for focal, subtle gray otherwise. + ctx.strokeStyle = isFocal ? "#ffffff" : "#374151"; + ctx.lineWidth = (isFocal ? 2 : 1) / globalScale; + ctx.stroke(); + + // Emoji centered. + ctx.font = + `${EMOJI_PX / globalScale}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(node.emoji, node.x, node.y); + + // Energy bar below tile. + const barW = BAR_WIDTH_PX / globalScale; + const barH = BAR_HEIGHT_PX / globalScale; + const barTop = node.y + half + BAR_GAP_ABOVE_PX / globalScale; + const barLeft = node.x - barW / 2; + const segGap = BAR_SEG_GAP_PX / globalScale; + const segW = (barW - segGap * (BAR_SEGMENTS - 1)) / BAR_SEGMENTS; + const segR = 1.5 / globalScale; + const { filled, color } = energyTier(node.percentile); + for (let i = 0; i < BAR_SEGMENTS; i++) { + const sx = barLeft + i * (segW + segGap); ctx.beginPath(); - ctx.arc(node.x, node.y, (screenRadius + 3) / globalScale, 0, 2 * Math.PI); - ctx.strokeStyle = "#ffffff"; - ctx.lineWidth = 2 / globalScale; + ctx.roundRect(sx, barTop, segW, barH, segR); + ctx.fillStyle = i < filled ? color : "#0f1419"; + ctx.fill(); + ctx.strokeStyle = "#374151"; + ctx.lineWidth = 0.5 / globalScale; ctx.stroke(); } - // Label below the node — text size + offsets divided by globalScale - // so they stay constant on screen regardless of zoom. + // Label below bar. const raw = String(node.label ?? node.id); const truncated = raw.length > 18 ? raw.slice(0, 17) + "…" : raw; const text = isFocal ? "★ " + truncated : truncated; @@ -176,9 +379,7 @@ export default function ForceGraph({ ctx.font = `${fontSize}px ui-sans-serif, system-ui, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; - const labelY = node.y + (screenRadius + 5) / globalScale; - - // Stroke first (background-color outline) for legibility on dark bg. + const labelY = barTop + barH + LABEL_GAP_PX / globalScale; ctx.lineWidth = 3 / globalScale; ctx.strokeStyle = "rgba(11, 13, 18, 0.9)"; ctx.strokeText(text, node.x, labelY); @@ -186,16 +387,85 @@ export default function ForceGraph({ ctx.fillText(text, node.x, labelY); }} linkColor={(l: any) => l.color} - linkWidth={1.5} - linkDirectionalParticles={(l: any) => - Math.max(1, Math.round(1 + (l.weight ?? 0.3) * 5)) - } - linkDirectionalParticleSpeed={(l: any) => 0.003 + (l.weight ?? 0.3) * 0.015} - linkDirectionalParticleWidth={(l: any) => 3 + (l.weight ?? 0.3) * 6} - linkDirectionalParticleColor={() => "#ffffff"} - linkDirectionalArrowLength={11} - linkDirectionalArrowRelPos={0.92} - linkDirectionalArrowColor={() => "#ffffff"} + linkWidth={1.2} + // Invisible particle kept ONLY to drive react-force-graph's per-frame + // redraw loop — without any particles the canvas stops repainting and + // our time-based footprint animation freezes. + linkDirectionalParticles={1} + linkDirectionalParticleWidth={0} + linkDirectionalParticleColor={() => "rgba(0,0,0,0)"} + linkCanvasObjectMode={() => "after"} + linkCanvasObject={(link: any, ctx: CanvasRenderingContext2D, globalScale: number) => { + const src = link.source; + const tgt = link.target; + if (!src || !tgt || typeof src !== "object" || typeof tgt !== "object") return; + const dx = tgt.x - src.x; + const dy = tgt.y - src.y; + const len = Math.hypot(dx, dy); + if (len < 2) return; + + // Keep footprints out of the tile area at each end so they don't + // disappear under the icons. TILE_PX/2 + a small gap. + const tileEdgeWorld = (TILE_PX / 2 + 10) / globalScale; + const marginFrac = Math.min(0.45, tileEdgeWorld / len); + const tStart = marginFrac; + const tEnd = 1 - marginFrac; + if (tEnd <= tStart) return; + + // Time-based phase. performance.now() gives us a steady frame clock. + const now = (typeof performance !== "undefined" ? performance.now() : Date.now()); + const phase = (now % FOOTPRINT_PERIOD_MS) / FOOTPRINT_PERIOD_MS; // 0..1 + + // Rotation that makes the footprint's local "up" axis point along + // the direction of travel (source → target). Derived from atan2(dy,dx) + // adjusted for canvas y-down convention. + const angle = Math.atan2(dy, dx) + Math.PI / 2; + // Unit perpendicular vector for left/right alternating gait offset. + const perpX = -dy / len; + const perpY = dx / len; + const footSize = FOOTPRINT_PX / globalScale; + const sideOffset = FOOTPRINT_SIDE_OFFSET_PX / globalScale; + + for (let i = 0; i < FOOTPRINT_COUNT; i++) { + let t = (phase + i / FOOTPRINT_COUNT) % 1; + t = tStart + t * (tEnd - tStart); + // Alternate L/R for a walking gait. Side is determined by foot index, + // not by time, so each foot keeps its lane as it advances. + const side = i % 2 === 0 ? 1 : -1; + const x = src.x + dx * t + perpX * sideOffset * side; + const y = src.y + dy * t + perpY * sideOffset * side; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + drawFootprint(ctx, footSize, FOOTPRINT_COLOR); + ctx.restore(); + } + + // Percentage capsule at midpoint — sits ON TOP of footprints so it's + // always readable. Color matches the weight tier. + const weight = (link.weight ?? 0) as number; + const pct = Math.round(weight * 100); + const text = `${pct}%`; + const capFont = 11 / globalScale; + ctx.font = `600 ${capFont}px ui-sans-serif, system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const textW = ctx.measureText(text).width; + const capW = textW + 10 / globalScale; + const capH = 17 / globalScale; + const capR = capH / 2; + const midX = src.x + dx * 0.5; + const midY = src.y + dy * 0.5; + ctx.beginPath(); + ctx.roundRect(midX - capW / 2, midY - capH / 2, capW, capH, capR); + ctx.fillStyle = weightCapsuleColor(weight); + ctx.fill(); + ctx.strokeStyle = "rgba(11, 13, 18, 0.55)"; + ctx.lineWidth = 1 / globalScale; + ctx.stroke(); + ctx.fillStyle = "#ffffff"; + ctx.fillText(text, midX, midY); + }} onNodeClick={(n: any) => onSelect?.(n.id)} cooldownTicks={0} warmupTicks={0} diff --git a/apps/web/components/RightRail.tsx b/apps/web/components/RightRail.tsx index 16eb5b9..fbc1ea0 100644 --- a/apps/web/components/RightRail.tsx +++ b/apps/web/components/RightRail.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import NodeVideo from "@/components/NodeVideo"; import WhatIfPanel from "@/components/WhatIfPanel"; import { impactColor } from "@/lib/color"; -import type { AnalysisResult, NodeAnalysis } from "@/lib/types"; +import type { AnalysisResult, NodeAnalysis, WhatIfState } from "@/lib/types"; type Tab = "ranking" | "report" | "citations" | "notes" | "whatif"; @@ -13,11 +13,15 @@ export default function RightRail({ selected, detail, onSimulate, + whatIf, + onWhatIf, }: { result: AnalysisResult; selected?: string | null; detail?: NodeAnalysis | null; onSimulate?: () => void; + whatIf?: WhatIfState | null; + onWhatIf?: (state: WhatIfState | null) => void; }) { const [tab, setTab] = useState("ranking"); const byId = new Map(result.node_analyses.map((a) => [a.node_id, a])); @@ -82,6 +86,10 @@ export default function RightRail({ {result.risk_ranking.map((id) => { const a = byId.get(id)!; + const modEpt = whatIf?.modified_ept?.[id]; + const baseEpt = whatIf?.baseline_ept?.[id] ?? a.ept; + const d = whatIf ? (modEpt ?? baseEpt) - baseEpt : 0; + const hasDelta = whatIf != null && Math.abs(d) > 1e-3; return ( {a.rank} @@ -93,7 +101,19 @@ export default function RightRail({ {labels.get(id)} {a.impact_score?.toFixed(2)} - {a.ept.toFixed(2)} + + {hasDelta ? ( + + {baseEpt.toFixed(2)}→ + {(modEpt ?? baseEpt).toFixed(2)} + 0 ? "ml-1 text-[10px] text-safe" : "ml-1 text-[10px] text-danger"}> + ({d > 0 ? "+" : ""}{d.toFixed(2)}) + + + ) : ( + a.ept.toFixed(2) + )} + {a.n_reachable} ); @@ -135,7 +155,9 @@ export default function RightRail({ )} - {tab === "whatif" && } + {tab === "whatif" && ( + {})} /> + )}
); diff --git a/apps/web/components/WhatIfPanel.tsx b/apps/web/components/WhatIfPanel.tsx index 50f8e20..efabd80 100644 --- a/apps/web/components/WhatIfPanel.tsx +++ b/apps/web/components/WhatIfPanel.tsx @@ -1,92 +1,148 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { whatif } from "@/lib/api"; -import type { AnalysisResult } from "@/lib/types"; +import type { AnalysisResult, WhatIfState } from "@/lib/types"; -interface WhatIfResult { - baseline_ept: Record; - modified_ept: Record; - delta: Record; - narration: string; -} +const DEBOUNCE_MS = 220; +const EPS = 1e-4; -export default function WhatIfPanel({ result }: { result: AnalysisResult }) { +export default function WhatIfPanel({ + result, + onChange, +}: { + result: AnalysisResult; + // Called when the live what-if state changes. `null` = no modification + // (slider sits at the baseline weight), so the ranking table reverts. + onChange: (state: WhatIfState | null) => void; +}) { const [edgeIdx, setEdgeIdx] = useState(0); - const [weight, setWeight] = useState(0.2); - const [out, setOut] = useState(null); + const edge = result.graph.edges[edgeIdx]; + const baseline = edge?.weight ?? 0; + const [weight, setWeight] = useState(baseline); const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const lastAbort = useRef(null); - const edge = result.graph.edges[edgeIdx]; + // When the user picks a different edge, snap the slider to that edge's baseline + // and clear any active what-if so the ranking table reverts. + useEffect(() => { + setWeight(baseline); + onChange(null); + setErr(null); + // intentionally not depending on onChange to avoid re-fire loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [edgeIdx]); - async function run() { - setBusy(true); - try { - const res = (await whatif(result.id, [ - { edge: { source: edge.source, target: edge.target }, new_weight: weight }, - ])) as WhatIfResult; - setOut(res); - } finally { - setBusy(false); + // Debounced /whatif call whenever the slider moves. + useEffect(() => { + if (!edge) return; + // No-op when slider is on baseline — clear what-if state instead of calling. + if (Math.abs(weight - baseline) < EPS) { + onChange(null); + setErr(null); + return; } - } + const handle = window.setTimeout(async () => { + lastAbort.current?.abort(); + const ctrl = new AbortController(); + lastAbort.current = ctrl; + setBusy(true); + setErr(null); + try { + const res = (await whatif( + result.id, + [{ edge: { source: edge.source, target: edge.target }, new_weight: weight }], + ctrl.signal, + )) as Omit; + if (ctrl.signal.aborted) return; + onChange({ + edgeKey: `${edge.source}→${edge.target}`, + newWeight: weight, + baselineWeight: baseline, + ...res, + }); + } catch (e: unknown) { + if (ctrl.signal.aborted) return; + const msg = e instanceof Error ? e.message : String(e); + if (!msg.toLowerCase().includes("abort")) setErr(msg); + } finally { + if (!ctrl.signal.aborted) setBusy(false); + } + }, DEBOUNCE_MS); + return () => window.clearTimeout(handle); + // onChange omitted from deps for stability (treated as a write sink). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [weight, edgeIdx, result.id]); - const movers = out - ? Object.entries(out.delta) - .filter(([, d]) => Math.abs(d) > 1e-6) - .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) - .slice(0, 5) - : []; - - return ( -
-
What-if
- - + if (!edge) return
No edges to modify.
; - - setWeight(Number(e.target.value))} - className="w-full" - /> + const delta = weight - baseline; + const pct = (weight * 100).toFixed(0); + const basePct = (baseline * 100).toFixed(0); - + return ( +
+
+ + +
- {out && ( -
- {movers.map(([id, d]) => ( -
- {id} - 0 ? "text-safe" : "text-danger"}> - {d > 0 ? "+" : ""} - {d.toFixed(2)} ({out.baseline_ept[id]?.toFixed(2)}→{out.modified_ept[id]?.toFixed(2)}) +
+
+ Weight + + baseline {basePct}% + + {pct}% + {Math.abs(delta) > EPS && ( + 0 ? "ml-2 text-safe" : "ml-2 text-danger"}> + ({delta > 0 ? "+" : ""}{(delta * 100).toFixed(0)}%) -
- ))} -

{out.narration}

+ )} +
- )} + setWeight(Number(e.target.value))} + className="w-full accent-blue-500" + aria-label="Edge weight" + /> +
+ 0% + 50% + 100% +
+
+ +
+ + {busy && computing…} + {err && {err}} + {!busy && !err && Math.abs(weight - baseline) >= EPS && ( + live · ranking table updated + )} +
); } diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 80ffb9b..5c4db30 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -52,11 +52,16 @@ export async function simulate( return jsonOrThrow(res); } -export async function whatif(analysis_id: string, modifications: unknown[]) { +export async function whatif( + analysis_id: string, + modifications: unknown[], + signal?: AbortSignal, +) { const res = await fetch("/api/v1/whatif", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ analysis_id, modifications }), + signal, }); return jsonOrThrow(res); } diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index a7c796b..70e5b72 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -76,6 +76,16 @@ export interface SimulateResponse { p99_rounds: number; } +// Live what-if state shared between the slider panel and the ranking table. +// `null` means "no modification active" (slider is at the baseline weight). +export interface WhatIfState { + edgeKey: string; // "source→target" + newWeight: number; + baselineWeight: number; + baseline_ept: Record; + modified_ept: Record; + delta: Record; + narration: string; export interface DraftNode { id: string; label: string; diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 249eb90..2db3c01 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -526,9 +526,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -545,9 +542,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -564,9 +558,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -583,9 +574,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -773,9 +761,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -790,9 +775,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -807,9 +789,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -824,9 +803,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -841,9 +817,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -858,9 +831,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -875,9 +845,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -892,9 +859,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -909,9 +873,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -926,9 +887,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -943,9 +901,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -960,9 +915,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -977,9 +929,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/services/backend/zeroforce_backend/engine/core.py b/services/backend/zeroforce_backend/engine/core.py index 5e24220..31dd5b9 100644 --- a/services/backend/zeroforce_backend/engine/core.py +++ b/services/backend/zeroforce_backend/engine/core.py @@ -126,16 +126,16 @@ def run_whatif( for mod in modifications: if mod.get("edge") and mod.get("new_weight") is not None: src, tgt = mod["edge"]["source"], mod["edge"]["target"] + new_w = max(0.0, min(1.0, float(mod["new_weight"]))) for e in edges: if e.source == src and e.target == tgt: e.weight = float(mod["new_weight"]) pinned.setdefault(tgt, set()).add(src) if mod.get("add_edge"): ae = mod["add_edge"] - from zeroforce.types import Edge - + new_w = max(0.0, min(1.0, float(ae["weight"]))) edges.append( - Edge(source=ae["source"], target=ae["target"], weight=float(ae["weight"]), + Edge(source=ae["source"], target=ae["target"], weight=new_w, confidence="medium", reasoning="what-if added edge") ) pinned.setdefault(ae["target"], set()).add(ae["source"])