|
| 1 | +import { GATEWAY_TIERS, GOVERNANCE_TIERS } from '../data/framework.js' |
| 2 | + |
| 3 | +// Layout constants for the 940x560 canvas. |
| 4 | +const G0 = { x: 60, y: 30, w: 220, h: 46 } |
| 5 | +const G1 = { x: 60, y: 110, w: 220, h: 46 } |
| 6 | +const TERMINALS = { |
| 7 | + app: { x: 60, y: 210, w: 150, h: 60, label: 'App' }, |
| 8 | + platform: { x: 250, y: 210, w: 150, h: 60, label: 'Platform' }, |
| 9 | + enterprise: { x: 440, y: 210, w: 150, h: 60, label: 'Enterprise' }, |
| 10 | +} |
| 11 | +const ESCALATIONS = { |
| 12 | + regulated: { x: 680, y: 30, w: 220, h: 46, label: 'Regulated data + exposure ≥ 2', target: 'enterprise', channelY: 284 }, |
| 13 | + monetized: { x: 680, y: 90, w: 220, h: 46, label: 'Commercial model ≥ 3 (monetized)', target: 'enterprise', channelY: 290 }, |
| 14 | + blastRadius: { x: 680, y: 150, w: 220, h: 46, label: 'Blast radius ≥ 3', target: 'platform', channelY: 296 }, |
| 15 | +} |
| 16 | +const METER = { x: 60, y: 300, w: 800, h: 24 } |
| 17 | +const BANDS = [ |
| 18 | + { key: 'light', frac: 5 / 19, range: '6–10', label: 'Tier 3 – Light' }, |
| 19 | + { key: 'managed', frac: 6 / 19, range: '11–16', label: 'Tier 2 – Managed' }, |
| 20 | + { key: 'full', frac: 8 / 19, range: '17–24', label: 'Tier 1 – Full' }, |
| 21 | +] |
| 22 | +const GOV_BOX_Y = 350 |
| 23 | +const GOV_BOX_H = 70 |
| 24 | + |
| 25 | +function bandLayout() { |
| 26 | + let x = METER.x |
| 27 | + return BANDS.map((b) => { |
| 28 | + const w = METER.w * b.frac |
| 29 | + const box = { ...b, x, w } |
| 30 | + x += w |
| 31 | + return box |
| 32 | + }) |
| 33 | +} |
| 34 | + |
| 35 | +const cx = (n) => n.x + n.w / 2 |
| 36 | +const cy = (n) => n.y + n.h / 2 |
| 37 | +const bottomMid = (n) => [cx(n), n.y + n.h] |
| 38 | + |
| 39 | +function DecisionNode({ node, active, lines }) { |
| 40 | + return ( |
| 41 | + <g> |
| 42 | + <rect className={`diagram-node${active ? ' active' : ''}`} x={node.x} y={node.y} width={node.w} height={node.h} rx={8} /> |
| 43 | + <text x={cx(node)} y={cy(node) + (lines.length > 1 ? -4 : 4)} textAnchor="middle" fontSize="12" fontWeight={active ? 700 : 500}> |
| 44 | + {lines.map((line, i) => ( |
| 45 | + <tspan key={i} x={cx(node)} dy={i === 0 ? 0 : 14}>{line}</tspan> |
| 46 | + ))} |
| 47 | + </text> |
| 48 | + </g> |
| 49 | + ) |
| 50 | +} |
| 51 | + |
| 52 | +function TerminalBox({ node, tierKey, active, title }) { |
| 53 | + return ( |
| 54 | + <g> |
| 55 | + <rect |
| 56 | + className={`diagram-node terminal${active ? ` active-${tierKey}` : ''}`} |
| 57 | + x={node.x} y={node.y} width={node.w} height={node.h} rx={8} |
| 58 | + > |
| 59 | + <title>{title}</title> |
| 60 | + </rect> |
| 61 | + <text x={cx(node)} y={cy(node) + 5} textAnchor="middle" fontSize="13">{node.label}</text> |
| 62 | + </g> |
| 63 | + ) |
| 64 | +} |
| 65 | + |
| 66 | +function EscalationBox({ node, active }) { |
| 67 | + return ( |
| 68 | + <g> |
| 69 | + <rect className={`diagram-node${active ? ' active' : ''}`} x={node.x} y={node.y} width={node.w} height={node.h} rx={8} /> |
| 70 | + {splitLabel(node.label).map((line, i) => ( |
| 71 | + <text key={i} x={cx(node)} y={cy(node) - 4 + i * 13} textAnchor="middle" fontSize="10.5" className={active ? '' : 'diagram-label-muted'}> |
| 72 | + {line} |
| 73 | + </text> |
| 74 | + ))} |
| 75 | + {active && ( |
| 76 | + <g transform={`translate(${node.x + node.w - 16}, ${node.y - 4})`}> |
| 77 | + <circle className="diagram-badge" r={8} /> |
| 78 | + <text className="diagram-badge-text" textAnchor="middle" y={3}>✓</text> |
| 79 | + </g> |
| 80 | + )} |
| 81 | + </g> |
| 82 | + ) |
| 83 | +} |
| 84 | + |
| 85 | +function splitLabel(label) { |
| 86 | + if (label.length <= 26) return [label] |
| 87 | + const mid = label.lastIndexOf(' ', 26) === -1 ? label.indexOf(' ') : label.lastIndexOf(' ', 26) |
| 88 | + return [label.slice(0, mid), label.slice(mid + 1)] |
| 89 | +} |
| 90 | + |
| 91 | +export default function DecisionTreeDiagram({ result }) { |
| 92 | + const dynamic = !!result |
| 93 | + const scores = result?.scores |
| 94 | + const gatewayKey = result?.gatewayKey |
| 95 | + const governanceKey = result?.governanceKey |
| 96 | + const totalScore = result?.totalScore |
| 97 | + |
| 98 | + const step1Taken = dynamic && scores.exposure >= 3 |
| 99 | + const step2Taken = dynamic && !step1Taken && (scores.exposure === 2 || scores.consumerCount >= 2) |
| 100 | + const step3Taken = dynamic && !step1Taken && !step2Taken |
| 101 | + |
| 102 | + const escalationActive = { |
| 103 | + regulated: dynamic && scores.dataSensitivity === 4 && scores.exposure >= 2, |
| 104 | + monetized: dynamic && scores.commercialModel >= 3, |
| 105 | + blastRadius: dynamic && scores.blastRadius >= 3, |
| 106 | + } |
| 107 | + |
| 108 | + const antiOverrideActive = dynamic && totalScore <= 10 && scores.dataSensitivity === 4 && governanceKey === 'full' |
| 109 | + |
| 110 | + const bands = bandLayout() |
| 111 | + const pointerX = dynamic |
| 112 | + ? Math.min(METER.x + METER.w, Math.max(METER.x, METER.x + ((totalScore - 6) / 18) * METER.w)) |
| 113 | + : null |
| 114 | + |
| 115 | + const [g0BotX, g0BotY] = bottomMid(G0) |
| 116 | + const [g1BotX, g1BotY] = bottomMid(G1) |
| 117 | + const entC = { x: cx(TERMINALS.enterprise), top: TERMINALS.enterprise.y } |
| 118 | + const platC = { x: cx(TERMINALS.platform), top: TERMINALS.platform.y } |
| 119 | + const appC = { x: cx(TERMINALS.app), top: TERMINALS.app.y } |
| 120 | + |
| 121 | + return ( |
| 122 | + <div> |
| 123 | + <div className="diagram-legend"> |
| 124 | + {dynamic ? ( |
| 125 | + <> |
| 126 | + <span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--brand)' }} /> Path taken</span> |
| 127 | + <span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--line)' }} /> Not taken</span> |
| 128 | + <span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: '#c0392b' }} /> Escalation fired</span> |
| 129 | + </> |
| 130 | + ) : ( |
| 131 | + <> |
| 132 | + <span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--line)' }} /> Decision step</span> |
| 133 | + <span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--ink-soft)' }} /> Escalation factor (can raise the tier)</span> |
| 134 | + <span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--tier-3)' }} /> Terminal tier</span> |
| 135 | + </> |
| 136 | + )} |
| 137 | + </div> |
| 138 | + |
| 139 | + <svg className="diagram-svg" viewBox="0 0 940 560"> |
| 140 | + {/* Gateway decision flow */} |
| 141 | + <path className={`diagram-edge${step2Taken || step3Taken ? ' active' : ''}`} d={`M${g0BotX},${g0BotY} L${g1BotX},${G1.y}`} /> |
| 142 | + <path className={`diagram-edge${step1Taken ? ' active' : ''}`} d={`M${G0.x + G0.w},${cy(G0)} L${entC.x},${cy(G0)} L${entC.x},${entC.top}`} /> |
| 143 | + <path className={`diagram-edge${step2Taken ? ' active' : ''}`} d={`M${G1.x + G1.w},${cy(G1)} L${platC.x},${cy(G1)} L${platC.x},${platC.top}`} /> |
| 144 | + <path className={`diagram-edge${step3Taken ? ' active' : ''}`} d={`M${g1BotX},${g1BotY} L${appC.x},${g1BotY} L${appC.x},${appC.top}`} /> |
| 145 | + |
| 146 | + <text x={G0.x + G0.w + 14} y={cy(G0) - 8} fontSize="10.5" className="diagram-label-muted">Yes</text> |
| 147 | + <text x={g0BotX + 8} y={(g0BotY + G1.y) / 2 + 4} fontSize="10.5" className="diagram-label-muted">No</text> |
| 148 | + <text x={G1.x + G1.w + 14} y={cy(G1) - 8} fontSize="10.5" className="diagram-label-muted">Yes</text> |
| 149 | + <text x={appC.x + 8} y={g1BotY + 12} fontSize="10.5" className="diagram-label-muted">No</text> |
| 150 | + |
| 151 | + <DecisionNode node={G0} active={dynamic} lines={['Exposure ≥ 3', '(Partner / Public)?']} /> |
| 152 | + <DecisionNode node={G1} active={dynamic && !step1Taken} lines={['Exposure = 2 or', '≥2 team consumers?']} /> |
| 153 | + |
| 154 | + <TerminalBox node={TERMINALS.app} tierKey="app" active={gatewayKey === 'app'} title={GATEWAY_TIERS.app.label} /> |
| 155 | + <TerminalBox node={TERMINALS.platform} tierKey="platform" active={gatewayKey === 'platform'} title={GATEWAY_TIERS.platform.label} /> |
| 156 | + <TerminalBox node={TERMINALS.enterprise} tierKey="enterprise" active={gatewayKey === 'enterprise'} title={GATEWAY_TIERS.enterprise.label} /> |
| 157 | + |
| 158 | + {/* Escalation factors */} |
| 159 | + {Object.entries(ESCALATIONS).map(([key, esc]) => { |
| 160 | + const target = TERMINALS[esc.target] |
| 161 | + const [botX, botY] = bottomMid(esc) |
| 162 | + const targetX = cx(target) |
| 163 | + return ( |
| 164 | + <path |
| 165 | + key={key} |
| 166 | + className={`diagram-edge escalation${escalationActive[key] ? ' active' : ''}`} |
| 167 | + d={`M${botX},${botY} L${botX},${esc.channelY} L${targetX},${esc.channelY} L${targetX},${target.y + target.h}`} |
| 168 | + /> |
| 169 | + ) |
| 170 | + })} |
| 171 | + <EscalationBox node={ESCALATIONS.regulated} active={escalationActive.regulated} /> |
| 172 | + <EscalationBox node={ESCALATIONS.monetized} active={escalationActive.monetized} /> |
| 173 | + <EscalationBox node={ESCALATIONS.blastRadius} active={escalationActive.blastRadius} /> |
| 174 | + |
| 175 | + {/* Anti-pattern override: regulated data forcing Tier 1 governance */} |
| 176 | + {(!dynamic || antiOverrideActive) && (() => { |
| 177 | + const full = bands.find((b) => b.key === 'full') |
| 178 | + const fullBoxCx = full.x + full.w / 2 |
| 179 | + const [botX, botY] = bottomMid(ESCALATIONS.regulated) |
| 180 | + return ( |
| 181 | + <path |
| 182 | + className={`diagram-edge escalation${antiOverrideActive ? ' active' : ''}`} |
| 183 | + d={`M${botX},${botY} L910,${botY} L910,330 L${fullBoxCx},330 L${fullBoxCx},${GOV_BOX_Y}`} |
| 184 | + /> |
| 185 | + ) |
| 186 | + })()} |
| 187 | + |
| 188 | + {/* Governance scoring flow */} |
| 189 | + {bands.map((b) => ( |
| 190 | + <rect |
| 191 | + key={b.key} |
| 192 | + className="diagram-meter-segment" |
| 193 | + x={b.x} y={METER.y} width={b.w} height={METER.h} |
| 194 | + fill={b.key === 'light' ? 'var(--tier-1)' : b.key === 'managed' ? 'var(--tier-2)' : 'var(--tier-3)'} |
| 195 | + opacity={dynamic && governanceKey !== b.key ? 0.35 : 1} |
| 196 | + /> |
| 197 | + ))} |
| 198 | + {bands.map((b) => ( |
| 199 | + <text key={b.key} x={b.x + b.w / 2} y={METER.y + METER.h / 2 + 4} textAnchor="middle" fontSize="11" fill="#fff" fontWeight="700"> |
| 200 | + {b.range} |
| 201 | + </text> |
| 202 | + ))} |
| 203 | + {dynamic && pointerX !== null && ( |
| 204 | + <polygon className="diagram-meter-pointer" points={`${pointerX - 7},${METER.y - 12} ${pointerX + 7},${METER.y - 12} ${pointerX},${METER.y - 1}`} /> |
| 205 | + )} |
| 206 | + {dynamic && pointerX !== null && ( |
| 207 | + <text x={pointerX} y={METER.y - 16} textAnchor="middle" fontSize="11" fontWeight="700">{totalScore}</text> |
| 208 | + )} |
| 209 | + |
| 210 | + {bands.map((b) => ( |
| 211 | + <g key={b.key}> |
| 212 | + <rect |
| 213 | + className={`diagram-node terminal${governanceKey === b.key ? ` active-${b.key === 'full' ? 'full' : b.key}` : ''}`} |
| 214 | + x={b.x} y={GOV_BOX_Y} width={b.w} height={GOV_BOX_H} rx={8} |
| 215 | + > |
| 216 | + <title>{GOVERNANCE_TIERS[b.key].label}</title> |
| 217 | + </rect> |
| 218 | + <text x={b.x + b.w / 2} y={GOV_BOX_Y + GOV_BOX_H / 2 + 5} textAnchor="middle" fontSize="13"> |
| 219 | + {b.label} |
| 220 | + </text> |
| 221 | + </g> |
| 222 | + ))} |
| 223 | + </svg> |
| 224 | + </div> |
| 225 | + ) |
| 226 | +} |
0 commit comments