File size: 48,770 Bytes
a48cb46 7a99d91 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#101d32"><meta name="description" content="FraudLens is an offline, explainable transaction-risk simulation and investigation workspace. All examples are synthetic.">
<title>FraudLens | Transaction Risk Intelligence Studio</title>
<style>
:root{font:13px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;--bg:#f3f6fa;--paper:#fff;--ink:#172740;--muted:#6a7890;--edge:#e2e9f1;--soft:#f5f7fb;--nav:#101d32;--accent:#534fd8;--teal:#14a897;--orange:#de9b40;--red:#d95d6b;--shadow:0 14px 40px #1725400a}
body.dark{color-scheme:dark;--bg:#0b1423;--paper:#152339;--ink:#eef3fb;--muted:#9bacc3;--edge:#293c54;--soft:#1d3046;--accent:#a5a1ff;--teal:#64e0ca;--orange:#ffca77;--red:#ff9a9e;--shadow:none}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}button,input,select{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.5}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #56daca;outline-offset:2px}a{color:var(--accent)}small{font-size:10px}.shell{display:grid;grid-template-columns:235px minmax(0,1fr);min-height:100vh}.sidebar{background:radial-gradient(circle at top left,#24375e,transparent 49%),linear-gradient(165deg,#121e35,#101a2e);color:#eaf4ff;position:sticky;top:0;height:100vh;padding:27px 16px 18px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:11px;margin:0 7px 35px}.mark{height:42px;width:42px;display:grid;place-items:center;border-radius:13px;background:linear-gradient(130deg,#a9f8e3,#8aa8ff);color:#12314c;font-size:24px;font-weight:950}.brand strong{font-size:18px;letter-spacing:-.7px}.brand b{color:#86eadc}.brand small{display:block;color:#9aaec7;text-transform:uppercase;letter-spacing:1.5px;font-size:8px;font-weight:900}.nav-heading{color:#8699b8;margin:0 12px 15px;font-size:9px;font-weight:900;letter-spacing:2px}.nav{display:grid;gap:6px}.nav button{background:transparent;text-align:left;color:#b1c0d3;border:1px solid transparent;border-radius:10px;padding:12px;display:flex;gap:12px;align-items:center;font-weight:720;font-size:11px}.nav button span{font-size:18px;width:23px;font-weight:400}.nav button:hover{color:#fff;background:#263754}.nav button.active{background:#303c61;color:white;border-color:#485d8a}.sidebar-bottom{margin-top:auto;border-top:1px solid #32435e;padding:17px 9px 0;color:#9caec8;font-size:10px}.live-dot{display:inline-block;height:7px;width:7px;background:#77efc8;border-radius:50%;margin-right:8px}.sidebar-bottom strong{color:#acf5dd}.main{width:100%;max-width:1660px;margin:0 auto;padding:25px clamp(15px,3vw,46px) 60px;min-width:0}.top{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:19px}.crumb{font-size:10px;font-weight:900;letter-spacing:1.5px;color:var(--muted)}.crumb strong{color:var(--ink)}.top-actions,.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.btn{border:1px solid var(--edge);border-radius:9px;background:var(--paper);color:var(--ink);font-size:11px;font-weight:800;padding:9px 13px;white-space:nowrap}.btn:hover,.mini:hover{border-color:var(--teal)}.btn.primary{background:#5552d7;color:white;border-color:#5552d7}.btn.ghost{background:transparent}.btn.warn{color:var(--red)}.btn.icon{font-size:16px;min-width:36px;min-height:35px;padding:5px}.avatar{border-radius:50%;background:#223f55;color:#95f9dc;height:35px;width:35px;display:grid;place-items:center;font-size:10px;font-weight:900}.view{display:none}.view.active{display:block}.hero{display:flex;justify-content:space-between;gap:25px;align-items:center;padding:28px 30px;border-radius:19px;background:linear-gradient(110deg,#172544,#353e80 65%,#157d84);color:#fff;position:relative;overflow:hidden}.hero:after{content:"";position:absolute;right:-85px;top:-175px;border:1px solid #ffffff30;border-radius:50%;width:430px;height:430px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff07;pointer-events:none}.eyebrow{font-size:10px;letter-spacing:1.7px;text-transform:uppercase;font-weight:900;color:#6ae6d1}.hero h1{letter-spacing:-1.25px;font-size:clamp(25px,3vw,36px);line-height:1.18;margin:8px 0 10px}.hero p{color:#d5dff4;max-width:565px;font-size:11.5px;margin:0}.hero .actions{margin-top:19px}.hero .btn{background:#e8fff7;border-color:#b6f4df;color:#176777}.hero .btn.alt{background:#ffffff16;color:white;border-color:#ffffff50}.hero-side{min-width:188px;z-index:1;border:1px solid #ffffff40;border-radius:15px;padding:17px;background:#ffffff16}.hero-side small{display:block;color:#d0e9f0}.hero-side strong{display:block;font-size:33px;letter-spacing:-1.2px;margin:3px 0}.hero-side em{color:#abffe4;font-size:10px;font-style:normal;font-weight:800}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:13px;margin:16px 0}.metric,.card{background:var(--paper);border:1px solid var(--edge);border-radius:15px;box-shadow:var(--shadow);min-width:0}.metric{padding:17px}.metric .label{display:flex;justify-content:space-between;gap:6px;font-size:10px;font-weight:750;color:var(--muted)}.metric .label b{font-size:16px;color:var(--teal)}.metric strong{display:block;font-size:clamp(23px,2.6vw,31px);margin:7px 0 2px;letter-spacing:-1px}.metric small{color:var(--muted)}.rowhead{display:flex;justify-content:space-between;gap:14px;align-items:flex-end;margin:25px 0 13px}.rowhead h2{margin:0;font-size:17px;letter-spacing:-.4px}.rowhead p{margin:4px 0 0;color:var(--muted);font-size:11px}.grid2{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(275px,1fr);gap:15px}.halves{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.card{padding:19px}.card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:15px}.card-head h3{margin:0;font-size:13px}.card-head p{font-size:10px;color:var(--muted);margin:4px 0 0}.pill{font-size:9px;font-weight:900;padding:5px 8px;border-radius:7px;background:var(--soft);border:1px solid var(--edge);color:var(--teal);white-space:nowrap}.risk{display:inline-block;border-radius:20px;padding:5px 9px;font-size:9px;font-weight:900;white-space:nowrap}.risk.high{color:#c84d5d;background:#fff0f1}.risk.mid{color:#ae791c;background:#fff6e7}.risk.low{color:#138d81;background:#eaf9f5}body.dark .risk.high{background:#59313b;color:#ffafb6}body.dark .risk.mid{background:#553e22;color:#ffcd84}body.dark .risk.low{background:#12433f;color:#9ce8d8}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;white-space:nowrap;text-align:left;font-size:11px}th{text-transform:uppercase;letter-spacing:.8px;font-size:9px;font-weight:900;color:var(--muted);padding:12px;border-bottom:1px solid var(--edge)}td{border-bottom:1px solid var(--edge);padding:12px}tbody tr:last-child td{border-bottom:none}.num{text-align:right;font-variant-numeric:tabular-nums}.mini{padding:6px 9px;border:1px solid var(--edge);border-radius:7px;background:var(--soft);color:var(--ink);font-weight:850;font-size:10px;cursor:pointer}.mini.red{color:var(--red)}.empty{padding:35px;text-align:center;color:var(--muted);font-size:11px}.chart{width:100%;height:auto;max-height:270px;display:block}.chart text{font-family:inherit;fill:var(--muted);font-size:10px}.legend{display:flex;flex-wrap:wrap;gap:13px;margin-top:9px;color:var(--muted);font-size:10px}.legend i{height:9px;width:9px;display:inline-block;border-radius:3px;margin-right:5px;background:var(--accent)}.legend .red i{background:var(--red)}.legend .teal i{background:var(--teal)}.baritem{margin-bottom:14px}.barhead{display:flex;justify-content:space-between;font-size:10px;margin-bottom:6px}.barhead span{color:var(--muted)}.track{background:var(--soft);border-radius:20px;height:9px;overflow:hidden}.fill{background:linear-gradient(90deg,#6c6bfc,#25b3b0);height:100%;border-radius:20px}.intro{display:flex;align-items:end;justify-content:space-between;gap:14px;margin:5px 0 22px}.intro h1{margin:5px 0;font-size:29px;letter-spacing:-.8px}.intro p{margin:0;color:var(--muted);font-size:11.5px;max-width:660px}.filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}.filters input,.filters select,.field input,.field select{min-width:0;width:100%;border:1px solid var(--edge);border-radius:9px;background:var(--paper);color:var(--ink);padding:10px 11px;font-size:11px}.filters input{max-width:270px}.filters select{width:155px}.casegrid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(270px,1fr);gap:14px}.case{position:sticky;top:10px;align-self:start}.detail{background:var(--soft);border:1px solid var(--edge);border-radius:12px;padding:14px}.detail h3{margin:0 0 7px;font-size:14px}.detail p{color:var(--muted);font-size:10px}.detailline{display:flex;justify-content:space-between;gap:8px;padding:9px 0;border-bottom:1px solid var(--edge);font-size:11px}.detailline:last-child{border:0}.detailline span{color:var(--muted)}.signal{margin:8px 0;border-radius:8px;padding:9px 10px;background:var(--paper);display:flex;justify-content:space-between;gap:5px;font-size:10px}.signal span{color:var(--muted)}.control{margin-bottom:18px}.control label{display:flex;justify-content:space-between;gap:10px;font-size:11px;font-weight:800;margin-bottom:10px}.control label span{color:var(--accent)}input[type=range]{width:100%;accent-color:#615bdc}.presets{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;margin-bottom:15px}.preset{padding:15px;min-width:0;text-align:left;background:var(--paper);color:var(--ink);border:1px solid var(--edge);border-radius:12px}.preset.selected{outline:2px solid var(--teal);background:var(--soft)}.preset b{display:block;font-size:11px;margin:7px 0 4px}.preset small{display:block;color:var(--muted);font-size:10px}.preset .emoji{font-size:22px}.minikpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:9px;margin:12px 0}.minikpi{padding:14px;background:var(--soft);border:1px solid var(--edge);border-radius:10px}.minikpi small{display:block;color:var(--muted)}.minikpi b{font-size:22px}.matrix{display:grid;grid-template-columns:90px 1fr 1fr;gap:8px;align-items:stretch;text-align:center;font-size:10px}.matrix > div{padding:14px 5px;border-radius:8px;background:var(--soft)}.matrix .head{display:grid;place-items:center;font-weight:900;color:var(--muted)}.matrix strong{font-size:25px;display:block}.matrix .correct{background:#e7f8f2;color:#147b6c}.matrix .incorrect{background:#fff0ef;color:#a24d56}body.dark .matrix .correct{background:#174239;color:#9be8c7}body.dark .matrix .incorrect{background:#4e2938;color:#ffb4bb}.notice{border:1px solid var(--edge);border-radius:10px;background:var(--soft);padding:12px;color:var(--muted);font-size:10.5px;margin:12px 0}.notice b{color:var(--ink)}.field{display:grid;gap:5px}.field label{color:var(--muted);font-weight:750;font-size:10px}.formgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.checkline{display:flex;align-items:center;gap:9px;font-size:11px}.checkline input{accent-color:#5b5ae2}.modalback{position:fixed;inset:0;background:#08152cc0;display:none;align-items:center;justify-content:center;padding:15px;z-index:20}.modalback.open{display:flex}.modal{width:100%;max-width:640px;max-height:min(90vh,870px);overflow:auto;border-radius:17px;background:var(--paper);color:var(--ink);box-shadow:0 20px 70px #0005;padding:24px}.modal h2{margin:0 0 15px;font-size:20px}.modal footer{display:flex;justify-content:flex-end;gap:9px;margin-top:19px}.toast{position:fixed;right:20px;bottom:18px;background:#182b42;color:#fff;box-shadow:0 9px 33px #0005;border-radius:10px;padding:12px 17px;z-index:30;opacity:0;transform:translateY(10px);transition:.2s;pointer-events:none;font-size:11px}.toast.show{opacity:1;transform:translateY(0)}.fineprint{margin-top:20px;color:var(--muted);font-size:10px}.methodgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.methodgrid h3{margin:0 0 7px;font-size:13px}.methodgrid p,.methodgrid li{color:var(--muted);font-size:11px}.methodgrid ul{padding-left:18px}.type-label{font-weight:850;color:var(--teal)}.sm-note{color:var(--muted);font-size:10px}.inline-note{font-size:9px;color:var(--muted)}
@media(max-width:1100px){.shell{grid-template-columns:195px minmax(0,1fr)}.grid2,.casegrid{grid-template-columns:1fr}.case{position:static}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:740px){.shell{display:block}.sidebar{position:static;width:100%;height:auto;padding:13px 15px}.brand{margin:0 0 11px}.brand strong{font-size:16px}.mark{height:35px;width:35px}.nav-heading,.sidebar-bottom{display:none}.nav{display:flex;overflow:auto;gap:5px}.nav button{flex-shrink:0;padding:8px 11px;font-size:10px}.nav button span{font-size:15px;width:auto}.main{padding:16px 13px 40px}.top{flex-wrap:wrap}.crumb{font-size:9px}.hero{padding:23px 20px}.hero-side{display:none}.hero h1{font-size:25px}.metrics{gap:9px}.metric{padding:13px}.metric strong{font-size:24px}.halves,.methodgrid{grid-template-columns:1fr}.presets{grid-template-columns:repeat(2,minmax(0,1fr))}.minikpis{grid-template-columns:repeat(2,minmax(0,1fr))}.card{padding:14px}.intro{display:block}.intro h1{font-size:25px}.top-actions .btn:first-child{display:none}.matrix{grid-template-columns:65px 1fr 1fr}.matrix > div{padding:12px 3px}.formgrid{grid-template-columns:1fr}.filters input{max-width:none}.filters select{flex:1;width:140px}.modal{padding:18px}.top .avatar{display:none}}
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand"><div class="mark">◇</div><div><strong>Fraud<b>Lens</b></strong><small>Transaction risk studio</small></div></div>
<p class="nav-heading">WORKSPACE</p>
<nav class="nav" aria-label="Main navigation">
<button class="active" data-view="overview"><span>▦</span> Overview</button>
<button data-view="review"><span>◉</span> Investigation queue</button>
<button data-view="lab"><span>◈</span> Threshold laboratory</button>
<button data-view="workspace"><span>▤</span> Transactions</button>
<button data-view="method"><span>◎</span> Methodology</button>
</nav>
<div class="sidebar-bottom"><span class="live-dot"></span><strong>Offline simulator</strong><p>PROJECT 090 · HUGDRIGO<br>All records and labels are synthetic. No real payment processing.</p></div>
</aside>
<main class="main">
<header class="top"><div class="crumb">HUGDRIGO / PROJECT 090 / <strong id="crumbView">OVERVIEW</strong></div><div class="top-actions"><button class="btn" id="exportTop">⇩ Export backup</button><button class="btn icon" id="themeBtn" aria-label="Toggle color theme">☾</button><button class="btn primary" data-add>+ Add transaction</button><span class="avatar">HF</span></div></header>
<section class="view active" id="view-overview">
<div class="hero"><div><div class="eyebrow">EXPLAINABLE RISK INTELLIGENCE</div><h1>Turn signals into decisions.</h1><p>Explore transaction risk with transparent scoring, a live review queue and synthetic ground-truth metrics. Built for learning, not real-world payment decisions.</p><div class="actions"><button class="btn" data-go="review">Review high-risk activity ↗</button><button class="btn alt" data-go="lab">Open threshold lab →</button></div></div><div class="hero-side"><small>Current alert threshold</small><strong id="heroThreshold">60</strong><em>Adjustable in the laboratory</em></div></div>
<div class="metrics" id="overviewMetrics"></div>
<div class="grid2"><div class="card"><div class="card-head"><div><h3>Risk distribution</h3><p>Active synthetic transactions grouped by rule-based score</p></div><span class="pill">LIVE MODEL</span></div><div id="riskChart"></div><div class="legend"><span><i></i>Below threshold</span><span class="red"><i></i>At or above threshold</span></div></div><div class="card"><div class="card-head"><div><h3>Primary risk signals</h3><p>Number of active records with each contributing signal</p></div><span class="pill">EXPLAINABLE</span></div><div id="signalBars"></div></div></div>
<div class="rowhead"><div><h2>Priority investigations</h2><p>Highest-scoring active records needing analyst attention</p></div><button class="btn" data-go="review">View all cases →</button></div>
<div class="card table-wrap" id="priorityTable"></div>
</section>
<section class="view" id="view-review"><div class="intro"><div><div class="eyebrow">INVESTIGATION DESK</div><h1>Review with context.</h1><p>Every score has an auditable explanation. Change case statuses without affecting the model's synthetic ground-truth label.</p></div><button class="btn primary" data-add>+ Add transaction</button></div>
<div class="filters"><input id="reviewSearch" type="search" placeholder="Search merchant or transaction ID" aria-label="Search transactions"><select id="reviewRisk" aria-label="Filter alert state"><option value="all">All risk scores</option><option value="alert">Alerts only</option><option value="clear">Below threshold</option></select><select id="reviewStatus" aria-label="Filter case status"><option value="all">All case statuses</option><option value="new">New</option><option value="investigating">Investigating</option><option value="closed">Closed</option></select></div>
<div class="casegrid"><div class="card table-wrap" id="reviewTable"></div><div class="card case" id="caseDetail"><div class="empty">Select a transaction to inspect its risk signals.</div></div></div>
</section>
<section class="view" id="view-lab"><div class="intro"><div><div class="eyebrow">THRESHOLD LABORATORY</div><h1>Explore detection trade-offs.</h1><p>Change alert cutoffs and simulated attack conditions. Precision and recall are calculated only against fictional demonstration labels.</p></div><button class="btn" id="resetLab">↺ Reset scenario</button></div>
<div class="rowhead"><div><h2>Scenario presets</h2><p>Apply synthetic changes to the same stored transactions</p></div></div><div class="presets" id="presetGrid"></div>
<div class="grid2"><div class="card"><div class="card-head"><div><h3>Risk rule controls</h3><p>All modifications are visible and reversible</p></div><span class="pill">WHAT IF?</span></div>
<div class="control"><label for="threshold">Alert cutoff <span id="thresholdOut">60 / 100</span></label><input id="threshold" type="range" min="0" max="100" step="1"></div>
<div class="control"><label for="velocity">Velocity multiplier <span id="velocityOut">1.0×</span></label><input id="velocity" type="range" min="0.5" max="3" step="0.1"></div>
<div class="control"><label for="failures">Added failed attempts <span id="failuresOut">+0</span></label><input id="failures" type="range" min="0" max="4" step="1"></div>
<div class="control"><label for="amountFactor">Amount multiplier <span id="amountOut">1.0×</span></label><input id="amountFactor" type="range" min="0.5" max="3" step="0.1"></div>
<p class="notice"><b>Scenario only:</b> Amount, velocity and failure adjustments change computed scores, but do not modify stored transaction inputs or fictional truth labels.</p>
</div><div class="card"><div class="card-head"><div><h3>Synthetic confusion matrix</h3><p>Alert decision versus simulated fraud ground truth</p></div><span class="pill">VALIDATION DEMO</span></div><div id="matrix" class="matrix"></div><div id="labKpis" class="minikpis"></div><p class="sm-note">If the denominator is zero, precision or recall is shown as N/A rather than 100%.</p></div></div>
<div class="rowhead"><div><h2>Threshold response</h2><p>Precision, recall and alert volume across candidate cutoffs</p></div></div><div class="card" id="thresholdChart"></div>
</section>
<section class="view" id="view-workspace"><div class="intro"><div><div class="eyebrow">DATA WORKSPACE</div><h1>Manage the sample ledger.</h1><p>Add, edit, archive or restore transactions. Everything stays in this browser unless you explicitly export a file.</p></div><button class="btn primary" data-add>+ Add transaction</button></div>
<div class="metrics" id="workspaceMetrics"></div><div class="card"><div class="card-head"><div><h3>Active records</h3><p>Model inputs, labels and review statuses</p></div><div class="actions"><button class="btn" id="csvBtn">⇩ Export CSV</button><button class="btn" id="backupBtn">⇩ Backup JSON</button></div></div><div class="table-wrap" id="workspaceTable"></div></div>
<div class="rowhead"><div><h2>Archived transactions</h2><p>Archived records are excluded from all current calculations</p></div></div><div class="card table-wrap" id="archiveTable"></div>
<div class="rowhead"><div><h2>Workspace controls</h2><p>Import validated data or reload the illustrative demo</p></div></div><div class="card"><div class="actions"><button class="btn" id="importBtn">⇧ Import JSON backup</button><button class="btn warn" id="demoReset">↺ Restore demonstration</button></div><input id="fileImport" type="file" accept="application/json,.json" hidden><p class="notice">Only FraudLens version 1 backups are accepted (maximum 300 records and 1 MB). Import replaces this local workspace only after confirmation. Export a backup first if you wish to keep your changes.</p></div>
</section>
<section class="view" id="view-method"><div class="intro"><div><div class="eyebrow">TRANSPARENT METHODOLOGY</div><h1>Every point is explainable.</h1><p>This demonstration deliberately uses fixed, readable rules. It does not claim to be trained on real fraud data or to detect actual financial crime.</p></div></div><div class="methodgrid"><div class="card"><h3>Risk features and points</h3><div id="rulesDescription"></div></div><div class="card"><h3>How metrics work</h3><p>A transaction is considered an <b>alert</b> when its computed risk score is at or above the adjustable cutoff.</p><ul><li><b>Precision = TP / (TP + FP)</b>, when alerts exist.</li><li><b>Recall = TP / (TP + FN)</b>, when labeled fraud exists.</li><li><b>False positives</b> are alerts on simulated legitimate cases.</li><li><b>True labels</b> are independent, synthetic demonstration annotations.</li></ul><p>Thresholds and signals do not learn from analyst actions or archived records.</p></div><div class="card"><h3>Limits and safeguards</h3><ul><li>All transaction identities, merchants and fraud labels are fictional.</li><li>Rule-based scoring is not a calibrated likelihood or probability.</li><li>There is no API, model inference, external service, or payment processing.</li><li>Local browser storage is not encrypted or suitable for sensitive personal data.</li><li>Scenario results are illustrative, not out-of-sample validation.</li></ul></div><div class="card"><h3>Portfolio implementation</h3><p>Built for Hugging Face Static Spaces with one self-contained HTML file, responsive layout, SVG graphs, offline JavaScript scoring, CSV export, validated JSON import and optional dark mode.</p><p>Portfolio owner: <strong>Hugdrigo</strong><br>Project number: <strong>#90</strong><br>License: <strong>MIT</strong></p></div></div></section>
<p class="fineprint">FraudLens © 2026 · HUGDRIGO / PROJECT 090 · Demonstration software only. Never use sample scores to make real financial decisions.</p>
</main>
</div>
<div class="modalback" id="modalBack" role="presentation"><div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle"><h2 id="modalTitle">Add transaction</h2><form id="txForm"><div class="formgrid">
<div class="field"><label for="merchant">Merchant / description</label><input id="merchant" name="merchant" maxlength="65" required placeholder="e.g. Northstar Commerce"></div>
<div class="field"><label for="amount">Amount (USD)</label><input id="amount" name="amount" type="number" min="0" max="1000000" step="0.01" required></div>
<div class="field"><label for="channel">Channel</label><select id="channel" name="channel"><option value="card-present">Card present</option><option value="card-not-present">Card not present</option><option value="bank-transfer">Bank transfer</option></select></div>
<div class="field"><label for="velocityInput">Transactions in past hour</label><input id="velocityInput" name="velocity" type="number" min="0" max="100" step="1" required></div>
<div class="field"><label for="failedInput">Failed attempts</label><input id="failedInput" name="failures" type="number" min="0" max="50" step="1" required></div>
<div class="field"><label for="ageInput">Account age in days</label><input id="ageInput" name="age" type="number" min="0" max="10000" step="1" required></div>
<div class="field"><label for="hourInput">Transaction hour (0-23)</label><input id="hourInput" name="hour" type="number" min="0" max="23" step="1" required></div>
<div class="field"><label for="truthInput">Fictional ground-truth label</label><select id="truthInput" name="truth"><option value="0">Legitimate</option><option value="1">Simulated fraud</option></select></div>
<label class="checkline"><input id="newDeviceInput" name="newDevice" type="checkbox"> New device</label><label class="checkline"><input id="newCountryInput" name="newCountry" type="checkbox"> Unusual country</label>
</div><footer><button class="btn" type="button" id="cancelModal">Cancel</button><button class="btn primary" type="submit">Save transaction</button></footer></form></div></div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
'use strict';
const STORE='fraudlens-v1-hugdrigo',MAX_RECORDS=300;
const el=id=>document.getElementById(id),clamp=(n,a,b)=>Math.min(b,Math.max(a,n));
const fmt=n=>new Intl.NumberFormat('en-US',{maximumFractionDigits:0}).format(n),money=n=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:2}).format(n);
const escapeHTML=x=>String(x).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const sampleRows=[
['Northstar Market',42,'card-present',1,0,840,14,0,0,0],['Summit Electronics',1490,'card-not-present',8,4,4,2,1,1,1],['Urban Coffee',12,'card-present',1,0,1200,8,0,0,0],['Nova Digital Goods',680,'card-not-present',6,3,7,1,1,1,1],['Bluebridge Apparel',92,'card-not-present',2,0,260,19,0,0,0],['Metro Transit',7,'card-present',1,0,490,9,0,0,0],['Acorn Supplies',390,'bank-transfer',5,2,23,3,1,1,1],['Harbor Books',34,'card-present',1,0,960,11,0,0,0],['Aurora Subscriptions',65,'card-not-present',4,0,130,21,0,0,0],['Horizon Gadgets',1280,'card-not-present',7,2,6,4,1,0,1],['Willow Grocers',54,'card-present',2,0,330,12,0,0,0],['Pioneer Fitness',84,'card-not-present',3,0,450,16,1,0,0],['Seabrook Travel',740,'card-not-present',4,1,190,23,0,1,0],['Atlas Express',410,'bank-transfer',6,3,12,2,1,1,1],['Evergreen Hardware',210,'card-present',1,0,610,17,0,0,0],['Fable Online',300,'card-not-present',5,1,72,1,1,0,1],['Cedar Restaurant',49,'card-present',1,0,820,20,0,0,0],['Helix Cloud Store',2200,'card-not-present',8,3,1,0,1,1,1],['Orchid Fashion',188,'card-not-present',2,1,44,15,0,0,0],['Oakside Pharmacy',28,'card-present',1,0,975,10,0,0,0]
];
const seeded=()=>sampleRows.map((r,i)=>({id:'FL-'+String(1001+i),merchant:r[0],amount:r[1],channel:r[2],velocity:r[3],failures:r[4],age:r[5],hour:r[6],newDevice:!!r[7],newCountry:!!r[8],truth:r[9],status:'new',archived:false}));
const presets={baseline:{name:'Baseline',icon:'◎',desc:'Original transaction conditions',velocity:1,failures:0,amount:1},surge:{name:'Velocity surge',icon:'↗',desc:'More transactions per hour',velocity:2,failures:0,amount:1},credential:{name:'Account attack',icon:'⌁',desc:'Multiple failed attempts',velocity:1,failures:3,amount:1},highvalue:{name:'High-value wave',icon:'◇',desc:'Higher transaction amounts',velocity:1.2,failures:0,amount:2.2}};
const defaults=()=>({version:1,theme:'light',threshold:60,scenario:{...presets.baseline,preset:'baseline'},transactions:seeded()});
function txValid(t){const s=v=>typeof v==='string'&&v.trim().length>0&&v.length<=65,inte=(n,a,b)=>Number.isInteger(n)&&n>=a&&n<=b,num=(n,a,b)=>typeof n==='number'&&Number.isFinite(n)&&n>=a&&n<=b;return t&&typeof t==='object'&&s(t.id)&&s(t.merchant)&&num(t.amount,0,1e6)&&['card-present','card-not-present','bank-transfer'].includes(t.channel)&&inte(t.velocity,0,100)&&inte(t.failures,0,50)&&inte(t.age,0,10000)&&inte(t.hour,0,23)&&typeof t.newDevice==='boolean'&&typeof t.newCountry==='boolean'&&[0,1].includes(t.truth)&&['new','investigating','closed'].includes(t.status)&&typeof t.archived==='boolean';}
function validState(s){return !!(s&&s.version===1&&['light','dark'].includes(s.theme)&&Number.isInteger(s.threshold)&&s.threshold>=0&&s.threshold<=100&&s.scenario&&typeof s.scenario==='object'&&Object.keys(presets).concat('custom').includes(s.scenario.preset)&&typeof s.scenario.velocity==='number'&&s.scenario.velocity>=.5&&s.scenario.velocity<=3&&Number.isInteger(s.scenario.failures)&&s.scenario.failures>=0&&s.scenario.failures<=4&&typeof s.scenario.amount==='number'&&s.scenario.amount>=.5&&s.scenario.amount<=3&&Array.isArray(s.transactions)&&s.transactions.length<=MAX_RECORDS&&s.transactions.every(txValid)&&new Set(s.transactions.map(x=>x.id)).size===s.transactions.length);}
function load(){try{const raw=localStorage.getItem(STORE);if(raw){const obj=JSON.parse(raw);if(validState(obj))return obj;}}catch(e){}return defaults();}
let state=load(),page='overview',selected=null,editing=null,toastTimer;
function save(){try{localStorage.setItem(STORE,JSON.stringify(state));}catch(e){toast('Browser storage is unavailable; export a backup.');}}
function scoreTransaction(t,sc=state.scenario){const a=t.amount*sc.amount,v=t.velocity*sc.velocity,f=t.failures+sc.failures,signals=[];const add=(pts,label)=>{if(pts>0)signals.push({points:pts,label});};add(a>=1000?14:a>=500?9:a>=250?5:0,'Higher transaction amount');add(v>=6?20:v>=4?12:v>=2?5:0,'High recent velocity');add(f>=3?21:f>=1?10:0,'Failed payment attempts');add(t.newDevice?13:0,'Unrecognized device');add(t.newCountry?12:0,'Unusual country');add(t.age<14?13:t.age<90?6:0,'Newer customer account');add(t.hour<=5?6:0,'Overnight activity');add(t.channel==='card-not-present'?5:0,'Remote card transaction');return {score:clamp(signals.reduce((n,s)=>n+s.points,0),0,100),signals};}
const active=()=>state.transactions.filter(t=>!t.archived);
const evaluated=()=>active().map(t=>({...t,risk:scoreTransaction(t),alert:scoreTransaction(t).score>=state.threshold}));
function confusion(rows=evaluated(),threshold=state.threshold){let tp=0,fp=0,tn=0,fn=0;for(const r of rows){const alert=r.risk.score>=threshold;if(alert&&r.truth)tp++;else if(alert)fp++;else if(r.truth)fn++;else tn++;}return {tp,fp,tn,fn,precision:tp+fp?tp/(tp+fp):null,recall:tp+fn?tp/(tp+fn):null};}
const percent=v=>v===null?'N/A':(v*100).toFixed(1)+'%';
const pill=r=>'<span class="risk '+(r>=70?'high':r>=40?'mid':'low')+'">'+r+'/100</span>';
function metric(title,value,note,icon){return '<div class="metric"><div class="label"><span>'+title+'</span><b>'+icon+'</b></div><strong>'+value+'</strong><small>'+note+'</small></div>';}
function toast(message){el('toast').textContent=message;el('toast').classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el('toast').classList.remove('show'),2700);}
function switchPage(name){if(!['overview','review','lab','workspace','method'].includes(name))return;page=name;document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.id==='view-'+name));document.querySelectorAll('[data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===name));el('crumbView').textContent=({overview:'OVERVIEW',review:'INVESTIGATION QUEUE',lab:'THRESHOLD LAB',workspace:'TRANSACTIONS',method:'METHODOLOGY'})[name];render();window.scrollTo(0,0);}
function riskSVG(rows){let bins=Array(10).fill(0), alerts=Array(10).fill(0);rows.forEach(r=>{const bin=Math.min(9,Math.floor(r.risk.score/10));bins[bin]++;if(r.alert)alerts[bin]++;});const M=Math.max(1,...bins),parts=[];for(let i=0;i<10;i++){const x=38+i*60;const h=165*bins[i]/M,ha=165*alerts[i]/M;parts.push('<rect x="'+x+'" y="'+(200-h)+'" width="37" height="'+h+'" rx="5" fill="var(--accent)" opacity=".62"/><rect x="'+x+'" y="'+(200-ha)+'" width="37" height="'+ha+'" rx="5" fill="var(--red)"/><text x="'+(x+18)+'" y="219" text-anchor="middle">'+(i*10)+'</text>');}return '<svg class="chart" viewBox="0 0 658 243" role="img" aria-label="Histogram of synthetic transaction risk scores"><line x1="28" y1="200" x2="644" y2="200" stroke="var(--edge)"/>'+parts.join('')+'<text x="15" y="15">'+M+' records at maximum bin</text><text x="640" y="238" text-anchor="end">Risk score bins (10 points)</text></svg>';}
function renderOverview(){const rows=evaluated(),c=confusion(rows),alerts=rows.filter(r=>r.alert),open=alerts.filter(r=>r.status!=='closed');el('heroThreshold').textContent=state.threshold+'/100';el('overviewMetrics').innerHTML=metric('ACTIVE TRANSACTIONS',fmt(rows.length),'Synthetic ledger records','▦')+metric('CURRENT ALERTS',fmt(alerts.length),fmt(open.length)+' not closed','◉')+metric('DEMO PRECISION',percent(c.precision),'Measured on fictional labels','◈')+metric('DEMO RECALL',percent(c.recall),'Measured on fictional labels','◎');el('riskChart').innerHTML=riskSVG(rows);
const keys=[['High recent velocity','velocity',r=>r.velocity*state.scenario.velocity>=4],['Failed attempts','failures',r=>r.failures+state.scenario.failures>=1],['New device','device',r=>r.newDevice],['Unusual country','country',r=>r.newCountry],['New account','age',r=>r.age<90]];el('signalBars').innerHTML=keys.map(([label,key,test])=>{const n=rows.filter(test).length;return '<div class="baritem"><div class="barhead"><b>'+label+'</b><span>'+n+' / '+rows.length+'</span></div><div class="track"><div class="fill" style="width:'+(rows.length?100*n/rows.length:0)+'%"></div></div></div>';}).join('');
const items=rows.filter(r=>r.status!=='closed').sort((a,b)=>b.risk.score-a.risk.score).slice(0,6);el('priorityTable').innerHTML=items.length?'<table><thead><tr><th>Transaction</th><th>Merchant</th><th class="num">Amount</th><th>Risk</th><th>Case state</th><th>Action</th></tr></thead><tbody>'+items.map(r=>'<tr><td><b>'+escapeHTML(r.id)+'</b></td><td>'+escapeHTML(r.merchant)+'</td><td class="num">'+money(r.amount)+'</td><td>'+pill(r.risk.score)+'</td><td>'+escapeHTML(r.status)+'</td><td><button class="mini" data-inspect="'+escapeHTML(r.id)+'">Inspect →</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">No active cases.</div>';}
function renderReview(){const query=el('reviewSearch').value.trim().toLowerCase(),risk=el('reviewRisk').value,status=el('reviewStatus').value;const rows=evaluated().filter(r=>(r.id.toLowerCase().includes(query)||r.merchant.toLowerCase().includes(query))&&(risk==='all'||(risk==='alert'&&r.alert)||(risk==='clear'&&!r.alert))&&(status==='all'||r.status===status)).sort((a,b)=>b.risk.score-a.risk.score);if(!selected||!active().some(t=>t.id===selected))selected=rows.length?rows[0].id:null;el('reviewTable').innerHTML=rows.length?'<table><thead><tr><th>Record</th><th>Merchant</th><th class="num">Amount</th><th>Risk</th><th>Case</th><th></th></tr></thead><tbody>'+rows.map(r=>'<tr><td><b>'+escapeHTML(r.id)+'</b></td><td>'+escapeHTML(r.merchant)+'</td><td class="num">'+money(r.amount)+'</td><td>'+pill(r.risk.score)+'</td><td>'+escapeHTML(r.status)+'</td><td><button class="mini" data-inspect="'+escapeHTML(r.id)+'">Details</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">No transactions match these filters.</div>';const t=evaluated().find(x=>x.id===selected);if(!t){el('caseDetail').innerHTML='<div class="empty">Select a transaction to inspect its risk signals.</div>';return;}
const detail='<div class="card-head"><div><h3>'+escapeHTML(t.id)+'</h3><p>'+escapeHTML(t.merchant)+'</p></div>'+pill(t.risk.score)+'</div><div class="detail"><div class="detailline"><span>Amount</span><b>'+money(t.amount)+'</b></div><div class="detailline"><span>Channel</span><b>'+escapeHTML(t.channel)+'</b></div><div class="detailline"><span>Case status</span><b>'+escapeHTML(t.status)+'</b></div><div class="detailline"><span>Simulated truth</span><b>'+(t.truth?'Fraud':'Legitimate')+'</b></div><div class="detailline"><span>Alert decision</span><b>'+(t.alert?'Alert':'Below cutoff')+'</b></div></div><div class="rowhead"><div><h2>Score breakdown</h2><p>Transparent points per signal</p></div></div>'+(t.risk.signals.length?t.risk.signals.map(s=>'<div class="signal"><span>'+escapeHTML(s.label)+'</span><strong>+'+s.points+'</strong></div>').join(''):'<p class="sm-note">No positive risk signals.</p>')+'<div class="notice">These indicators are deterministic rules, not probabilities of fraud.</div><div class="actions"><button class="btn" data-status="investigating" data-id="'+escapeHTML(t.id)+'">Investigate</button><button class="btn" data-status="closed" data-id="'+escapeHTML(t.id)+'">Close case</button><button class="btn ghost" data-status="new" data-id="'+escapeHTML(t.id)+'">Reopen</button></div>';
el('caseDetail').innerHTML=detail;}
function renderLab(){for(const [key,input,out,unit] of [['threshold','threshold','thresholdOut',' / 100'],['velocity','velocity','velocityOut','×'],['failures','failures','failuresOut',''],['amount','amountFactor','amountOut','×']]){const val=key==='threshold'?state.threshold:state.scenario[key];el(input).value=val;el(out).textContent=key==='failures'?'+'+val:key==='threshold'?val+unit:Number(val).toFixed(1)+unit;}el('presetGrid').innerHTML=Object.entries(presets).map(([key,x])=>'<button class="preset '+(state.scenario.preset===key?'selected':'')+'" data-preset="'+key+'"><span class="emoji">'+x.icon+'</span><b>'+x.name+'</b><small>'+x.desc+'</small></button>').join('');const rows=evaluated(),c=confusion(rows);el('matrix').innerHTML='<div class="head">Actual →<br>Prediction ↓</div><div class="head">Fictional fraud</div><div class="head">Fictional legit</div><div class="head">Alert</div><div class="correct"><strong>'+c.tp+'</strong>True positive</div><div class="incorrect"><strong>'+c.fp+'</strong>False positive</div><div class="head">No alert</div><div class="incorrect"><strong>'+c.fn+'</strong>False negative</div><div class="correct"><strong>'+c.tn+'</strong>True negative</div>';el('labKpis').innerHTML='<div class="minikpi"><small>Precision</small><b>'+percent(c.precision)+'</b></div><div class="minikpi"><small>Recall</small><b>'+percent(c.recall)+'</b></div><div class="minikpi"><small>Alerts</small><b>'+(c.tp+c.fp)+'</b></div><div class="minikpi"><small>False alarms</small><b>'+c.fp+'</b></div>';el('thresholdChart').innerHTML=thresholdSVG(rows);}
function thresholdSVG(rows){const vals=[];for(let t=0;t<=100;t+=5){const c=confusion(rows,t);vals.push({t,p:c.precision,r:c.recall,alerts:c.tp+c.fp});}const X=t=>42+t*5.9,Y=v=>180-145*v,poly=field=>vals.filter(x=>x[field]!==null).map(x=>X(x.t)+','+Y(x[field])).join(' '),grid=[0,.25,.5,.75,1].map(n=>'<line x1="42" x2="635" y1="'+Y(n)+'" y2="'+Y(n)+'" stroke="var(--edge)"/><text x="7" y="'+(Y(n)+4)+'">'+Math.round(n*100)+'%</text>').join('');let thresholdX=X(state.threshold);return '<svg class="chart" viewBox="0 0 660 234" role="img" aria-label="Precision and recall by alert cutoff, using synthetic labels">'+grid+'<polyline fill="none" stroke="var(--accent)" stroke-width="3" stroke-linejoin="round" points="'+poly('p')+'"/><polyline fill="none" stroke="var(--teal)" stroke-width="3" stroke-linejoin="round" points="'+poly('r')+'"/><line x1="'+thresholdX+'" x2="'+thresholdX+'" y1="20" y2="182" stroke="var(--red)" stroke-dasharray="5 4"/><text x="'+clamp(thresholdX+7,44,560)+'" y="16">Cutoff '+state.threshold+'</text>'+[0,20,40,60,80,100].map(t=>'<text x="'+X(t)+'" y="204" text-anchor="middle">'+t+'</text>').join('')+'<text x="630" y="224" text-anchor="end">Alert cutoff</text></svg><div class="legend"><span><i></i>Precision</span><span class="teal"><i></i>Recall</span><span class="red"><i></i>Current cutoff</span></div><div class="notice">These curves are calculated on the same small synthetic dataset. Metrics are descriptive, not evidence of model accuracy in production.</div>';}
function renderWorkspace(){const rows=evaluated(),arch=state.transactions.filter(t=>t.archived),c=confusion(rows);el('workspaceMetrics').innerHTML=metric('ACTIVE RECORDS',fmt(rows.length),'Included in current metrics','▦')+metric('ARCHIVED',fmt(arch.length),'Recoverable later','▤')+metric('SYNTHETIC FRAUD',fmt(rows.filter(x=>x.truth).length),'Fictional annotations','◉')+metric('ALERT VOLUME',fmt(c.tp+c.fp),'At current cutoff','◎');el('workspaceTable').innerHTML=rows.length?'<table><thead><tr><th>ID</th><th>Merchant</th><th class="num">Amount</th><th>Channel</th><th>Score</th><th>Label</th><th>Actions</th></tr></thead><tbody>'+rows.map(r=>'<tr><td><b>'+escapeHTML(r.id)+'</b></td><td>'+escapeHTML(r.merchant)+'</td><td class="num">'+money(r.amount)+'</td><td>'+escapeHTML(r.channel)+'</td><td>'+pill(r.risk.score)+'</td><td>'+(r.truth?'Fraud':'Legit')+'</td><td><div class="actions"><button class="mini" data-edit="'+escapeHTML(r.id)+'">Edit</button><button class="mini red" data-archive="'+escapeHTML(r.id)+'">Archive</button></div></td></tr>').join('')+'</tbody></table>':'<div class="empty">No active records. Add a transaction to begin.</div>';el('archiveTable').innerHTML=arch.length?'<table><thead><tr><th>Record</th><th>Merchant</th><th class="num">Amount</th><th>Action</th></tr></thead><tbody>'+arch.map(t=>'<tr><td>'+escapeHTML(t.id)+'</td><td>'+escapeHTML(t.merchant)+'</td><td class="num">'+money(t.amount)+'</td><td><button class="mini" data-restore="'+escapeHTML(t.id)+'">Restore</button></td></tr>').join('')+'</tbody></table>':'<div class="empty">Nothing is archived.</div>';}
function renderMethod(){const rules=[['Transaction amount','≥ $250: +5 · ≥ $500: +9 · ≥ $1,000: +14'],['Hourly velocity','≥ 2: +5 · ≥ 4: +12 · ≥ 6: +20'],['Failed attempts','≥ 1: +10 · ≥ 3: +21'],['Unrecognized device','+13'],['Unusual country','+12'],['Account age','< 90 days: +6 · < 14 days: +13'],['Hours 00:00 to 05:59','+6'],['Card-not-present channel','+5']];el('rulesDescription').innerHTML=rules.map(x=>'<div class="detailline"><span>'+escapeHTML(x[0])+'</span><b>'+escapeHTML(x[1])+'</b></div>').join('')+'<p class="sm-note">Final score = sum of triggered points, capped at 100. Scenario settings modify eligible input features before rules are applied.</p>';}
function render(){document.body.classList.toggle('dark',state.theme==='dark');el('themeBtn').textContent=state.theme==='dark'?'☀':'☾';({overview:renderOverview,review:renderReview,lab:renderLab,workspace:renderWorkspace,method:renderMethod})[page]();}
function updateTx(id,fn){const t=state.transactions.find(x=>x.id===id);if(!t)return;fn(t);save();render();}
function openModal(id){editing=id||null;const t=id?state.transactions.find(x=>x.id===id):null;el('modalTitle').textContent=t?'Edit transaction':'Add transaction';const defaults={merchant:'',amount:150,channel:'card-not-present',velocity:1,failures:0,age:180,hour:12,truth:0,newDevice:false,newCountry:false};const item=t||defaults;for(const key of ['merchant','amount','channel','velocity','failures','age','hour','truth'])el('txForm').elements[key].value=item[key];for(const key of ['newDevice','newCountry'])el('txForm').elements[key].checked=item[key];el('modalBack').classList.add('open');el('merchant').focus();}
function closeModal(){el('modalBack').classList.remove('open');editing=null;}
function uniqueId(){let result;do{result='FL-'+Date.now().toString(36).toUpperCase()+'-'+Math.random().toString(36).slice(2,6).toUpperCase();}while(state.transactions.some(x=>x.id===result));return result;}
function submitTx(e){e.preventDefault();const f=e.target.elements,t={id:editing||uniqueId(),merchant:f.merchant.value.trim(),amount:Number(f.amount.value),channel:f.channel.value,velocity:Number(f.velocity.value),failures:Number(f.failures.value),age:Number(f.age.value),hour:Number(f.hour.value),newDevice:f.newDevice.checked,newCountry:f.newCountry.checked,truth:Number(f.truth.value),status:editing?(state.transactions.find(x=>x.id===editing)?.status||'new'):'new',archived:false};if(!txValid(t)){toast('Check transaction values and try again.');return;}if(!editing&&state.transactions.length>=MAX_RECORDS){toast('Maximum 300 records reached.');return;}if(editing){const i=state.transactions.findIndex(x=>x.id===editing);if(i<0)return;t.archived=state.transactions[i].archived;state.transactions[i]=t;}else state.transactions.push(t);selected=t.id;closeModal();save();render();toast('Transaction saved locally.');}
function download(filename,content,type){const a=document.createElement('a'),url=URL.createObjectURL(new Blob([content],{type}));a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1500);}
function csvField(v){let s=String(v);if(/^\s*[=+@\-]/.test(s)&&!/^\s*-?\d+(?:\.\d+)?\s*$/.test(s))s="'"+s;return '"'+s.replace(/"/g,'""')+'"';}
function exportCSV(){const columns=['id','merchant','amount_usd','channel','velocity_per_hour','failed_attempts','account_age_days','hour','new_device','unusual_country','fictional_fraud_truth','review_status','risk_score','alert_at_cutoff','contributing_signals'];const rows=evaluated().map(t=>[t.id,t.merchant,t.amount,t.channel,t.velocity,t.failures,t.age,t.hour,t.newDevice,t.newCountry,t.truth,t.status,t.risk.score,t.alert,t.risk.signals.map(x=>x.label+':'+x.points).join('; ')]);download('fraudlens_synthetic_analysis.csv','\ufeff'+[columns,...rows].map(x=>x.map(csvField).join(',')).join('\r\n'),'text/csv;charset=utf-8');toast('CSV exported.');}
function backup(){download('fraudlens_workspace_backup.json',JSON.stringify(state,null,2),'application/json');toast('JSON backup exported.');}
async function importFile(file){if(!file)return;if(file.size>1024*1024){toast('Backup exceeds the 1 MB limit.');return;}try{const raw=await file.text(),obj=JSON.parse(raw);if(!validState(obj))throw Error('schema');if(!confirm('Replace this local workspace with the imported backup?'))return;state=obj;selected=null;save();render();toast('Validated backup restored.');}catch(e){toast('Invalid FraudLens version 1 backup.');}finally{el('fileImport').value='';}}
document.addEventListener('click',e=>{const target=e.target.closest('button');if(!target)return;const d=target.dataset;if(d.view)switchPage(d.view);else if(d.go)switchPage(d.go);else if(target.hasAttribute('data-add'))openModal();else if(d.inspect){selected=d.inspect;switchPage('review');}else if(d.edit)openModal(d.edit);else if(d.archive)updateTx(d.archive,t=>t.archived=true);else if(d.restore)updateTx(d.restore,t=>t.archived=false);else if(d.status&&d.id){updateTx(d.id,t=>t.status=d.status);toast('Case status updated.');}else if(d.preset){const p=presets[d.preset];state.scenario={...p,preset:d.preset};save();render();toast(p.name+' scenario loaded.');}});
for(const id of ['reviewSearch','reviewRisk','reviewStatus'])el(id).addEventListener(id==='reviewSearch'?'input':'change',()=>{if(page==='review')renderReview();});
for(const [id,key] of [['threshold','threshold'],['velocity','velocity'],['failures','failures'],['amountFactor','amount']])el(id).addEventListener('input',e=>{const n=Number(e.target.value);if(key==='threshold')state.threshold=n;else{state.scenario[key]=n;state.scenario.preset='custom';}save();renderLab();});
el('themeBtn').addEventListener('click',()=>{state.theme=state.theme==='dark'?'light':'dark';save();render();});
el('resetLab').addEventListener('click',()=>{state.threshold=60;state.scenario={...presets.baseline,preset:'baseline'};save();render();toast('Default laboratory settings restored.');});
el('txForm').addEventListener('submit',submitTx);el('cancelModal').addEventListener('click',closeModal);el('modalBack').addEventListener('click',e=>{if(e.target===el('modalBack'))closeModal();});document.addEventListener('keydown',e=>{if(e.key==='Escape'&&el('modalBack').classList.contains('open'))closeModal();});
el('csvBtn').addEventListener('click',exportCSV);el('backupBtn').addEventListener('click',backup);el('exportTop').addEventListener('click',backup);el('importBtn').addEventListener('click',()=>el('fileImport').click());el('fileImport').addEventListener('change',e=>importFile(e.target.files[0]));el('demoReset').addEventListener('click',()=>{if(!confirm('Replace the local workspace with the original synthetic demonstration records?'))return;state=defaults();selected=null;save();render();toast('Demonstration workspace restored.');});
render();
</script>
</body></html>
|