-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
4994 lines (4521 loc) · 334 KB
/
Copy pathapp.js
File metadata and controls
4994 lines (4521 loc) · 334 KB
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* Word-level Enron color rotation for title */
(function() {
const wordColors = ['#0080C0', '#C03028', '#209050', '#C49032'];
const lines = [
{ el: 'line1', text: 'Organizational Knowledge' },
{ el: 'line2', text: 'Decay Modeling' },
];
let wordIdx = 0;
for (const line of lines) {
const el = document.getElementById(line.el);
const words = line.text.split(' ');
for (let w = 0; w < words.length; w++) {
const wordSpan = document.createElement('span');
wordSpan.className = 'title-word';
wordSpan.style.color = wordColors[wordIdx % wordColors.length];
for (const ch of words[w]) {
const letter = document.createElement('span');
letter.className = 'title-letter';
letter.textContent = ch;
wordSpan.appendChild(letter);
}
el.appendChild(wordSpan);
if (w < words.length - 1) el.appendChild(document.createTextNode(' '));
wordIdx++;
}
}
})();
document.addEventListener('keydown', (e) => { if (e.key === 'Enter') enterDashboard(); });
/* Global — called from inline onclick and keydown handler */
let coverEntered = false;
function enterDashboard() {
if (coverEntered) return;
coverEntered = true;
document.getElementById('page').classList.add('exiting');
setTimeout(() => {
document.getElementById('cover-page').style.display = 'none';
const dashboard = document.getElementById('dashboard');
// Explicit layout mirrors the original body-level flex context.
// height/width must be set inline so D3 sees real dimensions on rebuild.
dashboard.style.display = 'flex';
dashboard.style.flexDirection = 'column';
dashboard.style.height = '100vh';
dashboard.style.width = '100vw';
dashboard.style.overflow = 'hidden';
dashboard.style.opacity = '0';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
dashboard.style.transition = 'opacity 0.5s ease';
dashboard.style.opacity = '1';
// After fade completes, fire resize so every D3 chart recomputes its
// SVG dimensions — they were 0 when #dashboard was display:none at init.
setTimeout(() => window.dispatchEvent(new Event('resize')), 550);
// Reveal back button after dashboard is visible
const btn = document.getElementById('backToCover');
if (btn) btn.style.display = 'block';
});
});
}, 800);
}
function goBackToCover() {
const dashboard = document.getElementById('dashboard');
dashboard.style.transition = 'opacity 0.4s ease';
dashboard.style.opacity = '0';
const btn = document.getElementById('backToCover');
if (btn) btn.style.display = 'none';
setTimeout(() => {
dashboard.style.display = 'none';
const cp = document.getElementById('cover-page');
cp.style.display = '';
// Reset so Enter Model works again
coverEntered = false;
const page = document.getElementById('page');
page.classList.remove('exiting');
page.style.opacity = '1';
page.style.transform = '';
}, 400);
}
function showHow() { document.getElementById('howModal').classList.add('visible'); }
function hideHow() { document.getElementById('howModal').classList.remove('visible'); }
// ---- next embedded script block ----
"use strict";
// ── State ──────────────────────────────────────────────────────────────────────
let DATA = null;
let removedId = null;
let simMonth = 11; // 0-indexed; default = month 12 (last)
// ── Multi-removal state ────────────────────────────────────────────────────────
let multiSelectActive = false;
let multiQueue = []; // array of email strings in selection order
let multiSimRunning = false;
let multiSimDone = false;
let multiOrigRisks = {}; // email → original risk_score (0–1)
let multiLiveRisks = {}; // email → boosted risk_score (0–1) after cascade
// ── Cascade state (persists across view switches) ──────────────────────────────
let cascadeState = null; // null when no cascade; populated after runMultiCascade()
const QUADRANT_DESC = {
"Organizational Emergency": "Irreplaceable expertise in a senior role. Priority intervention required.",
"Silent Threat": "Critical knowledge held by a junior employee. Often missed until departure.",
"Replaceable Executive": "Senior role but knowledge is distributed. Leadership gap, not knowledge gap.",
"Low Priority": "Redundant knowledge in a replaceable role. Standard succession planning.",
};
let currentView = "oi";
let gvBuilt = false;
let gvCurrentTab = "quadrant";
let gvScatterDots = null;
let gvNetNe = null;
let gvNetLe = null;
let gvNetGc = null;
let gvNetSim2 = null;
let gvNetBuilt = false;
let gvSelectedId = null;
let gvEdgesByPerson = {};
let gvScatterX = null;
let gvScatterY = null;
let gvScatterXSel = null;
let gvScatterYSel = null;
let gvScatterXAxis = null;
let gvScatterYAxis = null;
let gvScatterLabel = null;
let gvScatterWatermark = null;
let gvScatterZoom = null;
let gvScatterBgs = null;
let gvScatterThreshLines = null;
let gvScatterQLabels = null;
let gvNavPrev = null;
let gvNetIsolated = false;
const GV_KR_T = 0.12, GV_PI_T = 0.65;
const GV_ZOOM_DOMAINS = {
"Organizational Emergency": { x: [0.10, 0.55], y: [0.62, 1.00] },
"Silent Threat": { x: [0.10, 0.55], y: [0.00, 0.67] },
"Replaceable Executive": { x: [0.00, 0.22], y: [0.62, 1.00] },
"Low Priority": { x: [0.00, 0.22], y: [0.00, 0.67] },
};
const GV_Q_RGBA = {
"Organizational Emergency": "212,52,46",
"Silent Threat": "196,144,50",
"Replaceable Executive": "0,114,188",
"Low Priority": "45,140,60",
};
const GV_Q_HEX = {
"Organizational Emergency": "#D4342E",
"Silent Threat": "#C49032",
"Replaceable Executive": "#0072BC",
"Low Priority": "#2D8C3C",
};
const GV_DEPT_COLORS = {
"Legal": "#0072BC",
"Trading": "#C49032",
"Executive": "#D4342E",
"Research": "#2D8C3C",
"Regulatory": "#9B59B6",
"Operations": "#48484A",
"Communications": "#6E6E73",
"Administration": "#A1A1A6",
};
// Graph data (top 50)
let g50nodes = [];
let g50edges = [];
// Persistent D3 graph references — set by buildGraph(), used by departGraph()
let gSim = null;
let gLe = null; // edge lines
let gGc = null; // glow halos
let gPc = null; // pulse overlay circles
let gNe = null; // node circles
let gLa = null; // labels
let gSt = null; // (reserved — unused)
// ── Name / label helpers ───────────────────────────────────────────────────────
// Prefer display_name baked into the data by export_dashboard_data.py.
// Fall back to formatting the email local part for any address not in the lookup.
const _nameCache = {};
function formatName(email) {
if (!email) return "Unknown";
if (_nameCache[email]) return _nameCache[email];
// Try DATA.display_names lookup (populated after data loads)
if (DATA && DATA.display_names) {
const n = DATA.display_names[email.toLowerCase()];
if (n) { _nameCache[email] = n; return n; }
}
// Fallback: parse from email local part
const local = email.split("@")[0].replace(/^'+|'+$/g, ""); // strip stray quotes
const parts = local.split(/[._]+/).filter(p => p.length > 0 && !/^\d+$/.test(p));
const name = parts.length
? parts.map(p => p[0].toUpperCase() + p.slice(1).toLowerCase()).join(" ")
: email;
_nameCache[email] = name;
return name;
}
function lastName(email) {
const parts = formatName(email).split(" ");
return parts.length > 1 ? parts[parts.length - 1] : parts[0];
}
function empRole(person) {
// Role is inferred from graph metrics in export_dashboard_data.py
return person.role || "—";
}
function empTopics(person) {
// Show the top topic's parent category (from topic_categories mapping)
const tp = person.topic_profile;
if (!tp || !tp.length) return "";
// Collect unique categories across top topics, show top 2
const cats = [];
for (const t of tp) {
const cat = t.category
|| (DATA.topic_categories && DATA.topic_categories[String(t.topic)])
|| "";
if (cat && !cats.includes(cat)) cats.push(cat);
if (cats.length >= 2) break;
}
return cats.join(" · ");
}
function topicLabel(topicId, inlineCategory, inlineWords) {
// Prefer category name; fall back to words; fall back to "Topic N"
if (inlineCategory) return inlineCategory;
if (DATA && DATA.topic_categories && DATA.topic_categories[String(topicId)])
return DATA.topic_categories[String(topicId)];
if (inlineWords) return inlineWords;
const w = DATA && DATA.topic_words && DATA.topic_words[String(topicId)];
return w || (topicId != null ? `Topic ${topicId}` : "—");
}
function riskClass(r) {
if (r >= 0.40) return "risk-high";
if (r >= 0.15) return "risk-med";
return "risk-low";
}
function quadrantBadgeClass(quadrant) {
if (quadrant === "Organizational Emergency") return "risk-high";
if (quadrant === "Silent Threat") return "risk-med";
return "risk-low";
}
function riskColor(r) {
if (r >= 0.40) return "#D4342E";
if (r >= 0.15) return "#C49032";
return "#F5F5F7";
}
function nodeColor(r) {
if (r >= 0.40) return "#D4342E";
if (r >= 0.15) return "#0072BC";
return "#2D8C3C";
}
function nodeGlowFilter(r) {
if (r >= 0.40) return "url(#glow-high)";
if (r >= 0.15) return "url(#glow-mid)";
return "url(#glow-low)";
}
function statusRank(s) {
return s === "recovered" ? 2 : s === "partial" ? 1 : 0;
}
// ── Fetch ──────────────────────────────────────────────────────────────────────
fetch("dashboard_data.json")
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(d => {
DATA = d;
// Build a flat display_names lookup from people + graph nodes
DATA.display_names = {};
(d.people || []).forEach(p => {
if (p.display_name) DATA.display_names[p.person.toLowerCase()] = p.display_name;
});
(d.graph && d.graph.nodes || []).forEach(n => {
if (n.display_name) DATA.display_names[n.id.toLowerCase()] = n.display_name;
});
document.getElementById("loadingOverlay").style.display = "none";
init();
})
.catch(err => {
document.getElementById("loadingOverlay").innerHTML =
`<div style="text-align:center;line-height:2.2;font-family:'JetBrains Mono',monospace">
<div style="color:var(--text)">failed to load dashboard_data.json</div>
<div style="color:var(--text-faint);font-size:11px">${err.message}</div>
<div style="color:var(--text-faint);font-size:11px">run: python export_dashboard_data.py</div>
</div>`;
});
// ── Init ───────────────────────────────────────────────────────────────────────
function init() {
// Top 50 graph nodes
g50nodes = (DATA.graph.nodes || []).slice(0, 50);
const ids50 = new Set(g50nodes.map(n => n.id));
g50edges = (DATA.graph.edges || []).filter(e => ids50.has(e.source) && ids50.has(e.target));
const maxW = Math.max(...g50edges.map(e => e.weight), 1);
g50edges.forEach(e => { e.wn = e.weight / maxW; });
const uniqueCategories = new Set(
Object.values(DATA.topic_categories || {}).filter(Boolean)
).size || DATA.topics.length;
document.getElementById("headerSubtitle").textContent =
`${DATA.people.length} employees · ${uniqueCategories} topic categories · 12-month simulation`;
// Build edge lookup for graph view (all edges, not just top-50)
gvEdgesByPerson = {};
(DATA.graph.edges || []).forEach(e => {
if (!gvEdgesByPerson[e.source]) gvEdgesByPerson[e.source] = [];
if (!gvEdgesByPerson[e.target]) gvEdgesByPerson[e.target] = [];
gvEdgesByPerson[e.source].push({ partner: e.target, weight: e.weight });
gvEdgesByPerson[e.target].push({ partner: e.source, weight: e.weight });
});
renderEmployeeList();
buildGraph();
window.addEventListener("resize", () => {
if (currentView === "oi") buildGraph();
else if (currentView === "graph") {
requestAnimationFrame(() => {
if (gvCurrentTab === "quadrant") buildGvScatter();
else buildGvNetwork();
});
} else if (currentView === "ai" && aiBuilt) {
requestAnimationFrame(() => buildAIScatter());
}
});
}
// ── Employee list ──────────────────────────────────────────────────────────────
function renderEmployeeList(filter) {
const fl = (filter || "").toLowerCase();
const list = document.getElementById("employeeList");
list.innerHTML = "";
DATA.people.forEach(p => {
const name = p.display_name || formatName(p.person);
const role = empRole(p);
const topics = empTopics(p);
if (fl && !name.toLowerCase().includes(fl) && !role.toLowerCase().includes(fl) && !topics.toLowerCase().includes(fl)) return;
const isQueued = multiQueue.includes(p.person);
const isMultiRemoved = multiSimDone && isQueued;
const isSingleSel = !multiSelectActive && removedId === p.person;
const dispRisk = (multiSimDone && !isQueued && multiLiveRisks[p.person] != null)
? multiLiveRisks[p.person] : p.risk_score;
const badgeId = "mrisk_" + p.person.replace(/[^a-z0-9]/gi, "_");
const div = document.createElement("div");
div.className = "employee"
+ (isSingleSel ? " selected removed" : "")
+ (isQueued && multiSelectActive && !multiSimDone ? " queued" : "")
+ (isMultiRemoved ? " removed" : "");
div.dataset.id = p.person;
div.innerHTML = `
<div class="emp-info">
<div class="emp-name">${name}</div>
<div class="emp-role">${role}</div>
<div class="emp-topics">${topics}</div>
${p.quadrant ? `<span class="emp-quadrant-pill" style="color:${p.quadrant_color};background:${p.quadrant_color}26">${p.quadrant}</span>` : ""}
</div>
<span id="${badgeId}" class="risk-badge ${quadrantBadgeClass(p.quadrant)}"><span>${(dispRisk * 100).toFixed(0)}</span><span class="risk-badge-sub">/100</span></span>
`;
div.addEventListener("click", () => {
if (multiSelectActive && !multiSimRunning && !multiSimDone) {
multiHandleEmployeeClick(p.person);
} else if (!multiSelectActive) {
simulateDeparture(p.person);
}
});
list.appendChild(div);
});
}
function filterEmployees(v) { renderEmployeeList(v); }
// ── Multi-removal simulation ────────────────────────────────────────────────────
function toggleMultiSelect() {
multiSelectActive = !multiSelectActive;
document.getElementById("multiToggle").classList.toggle("active", multiSelectActive);
document.getElementById("departureQueue").classList.toggle("visible", multiSelectActive);
if (!multiSelectActive) {
// Turning off — reset everything
multiSimDone = false; multiSimRunning = false;
multiOrigRisks = {}; multiLiveRisks = {};
multiQueue = [];
renderMultiQueue();
}
renderEmployeeList(document.getElementById("searchBox").value);
}
function multiHandleEmployeeClick(email) {
const idx = multiQueue.indexOf(email);
if (idx >= 0) {
multiQueue.splice(idx, 1);
} else {
if (multiQueue.length >= 5) return;
multiQueue.push(email);
}
renderMultiQueue();
renderEmployeeList(document.getElementById("searchBox").value);
}
function removeFromMultiQueue(email) {
multiQueue = multiQueue.filter(e => e !== email);
renderMultiQueue();
renderEmployeeList(document.getElementById("searchBox").value);
}
function clearMultiQueue() {
multiQueue = []; multiSimRunning = false; multiSimDone = false;
multiOrigRisks = {}; multiLiveRisks = {};
renderMultiQueue();
renderEmployeeList(document.getElementById("searchBox").value);
document.getElementById("rightPanel").innerHTML = `
<div class="empty-state">
<div class="empty-state-title">Select an employee</div>
<div class="empty-state-hint">Click any name to simulate their departure</div>
</div>`;
}
function renderMultiQueue() {
const chips = document.getElementById("queueChips");
if (!chips) return;
chips.innerHTML = multiQueue.map((email, i) => {
const person = DATA && DATA.people.find(p => p.person === email);
const name = (person && (person.display_name || formatName(email))) || formatName(email);
const risk = person ? (person.risk_score * 100).toFixed(1) : "?";
const safeEmail = email.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
return `<div class="queue-chip">
<div class="chip-info">
<span class="chip-order">${i + 1}</span>
<span class="chip-name">${name}</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span class="chip-risk">${risk}</span>
<span class="chip-remove" onclick="event.stopPropagation();removeFromMultiQueue('${safeEmail}')">×</span>
</div>
</div>`;
}).join("");
document.getElementById("queueCount").textContent = multiQueue.length;
const btn = document.getElementById("simulateBtn");
if (btn) btn.disabled = multiQueue.length < 2;
const lim = document.getElementById("queueLimit");
if (lim) lim.textContent = multiQueue.length >= 5 ? "Maximum 5 reached" : `${5 - multiQueue.length} slots remaining`;
}
// Departure animation for cascade — additive (no restore of previous person)
function departGraphAdditive(personId) {
if (!gNe) return;
const connectedIds = new Set();
g50edges.forEach(e => {
if (e.source === personId || (e.source && e.source.id === personId)) connectedIds.add(e.target && e.target.id ? e.target.id : e.target);
if (e.target === personId || (e.target && e.target.id === personId)) connectedIds.add(e.source && e.source.id ? e.source.id : e.source);
});
gNe.filter(n => n.id === personId).transition().duration(500).ease(d3.easeCubicIn).attr("r", 0).attr("opacity", 0);
gGc.filter(n => n.id === personId).transition().duration(500).attr("r", 0).attr("opacity", 0);
gLa.filter(n => n.id === personId).transition().duration(300).attr("opacity", 0);
gLe.each(function(l) {
const src = typeof l.source === "object" ? l.source.id : l.source;
const tgt = typeof l.target === "object" ? l.target.id : l.target;
if (src === personId || tgt === personId) {
d3.select(this).transition().duration(500).attr("stroke", "#D4342E").attr("stroke-dasharray", "4 4").attr("opacity", 0.35);
}
});
gNe.filter(n => connectedIds.has(n.id))
.transition().delay(200).duration(300).ease(d3.easeElasticOut.amplitude(1.2))
.attr("r", d => (8 + d.risk_score * 20) * 1.35)
.transition().duration(500).attr("r", d => 8 + d.risk_score * 20);
gPc.filter(n => connectedIds.has(n.id))
.attr("r", d => 8 + d.risk_score * 20 + 8).attr("opacity", 0.4).attr("filter", "url(#pulse-glow)")
.transition().delay(200).duration(800).ease(d3.easeCubicOut).attr("opacity", 0).attr("r", 0);
gGc.filter(n => connectedIds.has(n.id))
.transition().delay(300).duration(400).attr("fill", "#D4342E").attr("opacity", 0.25)
.transition().delay(1500).duration(1000)
.attr("fill", d => {
if (d.risk_score >= 0.40) return "rgba(212,52,46,0.3)";
if (d.risk_score >= 0.15) return "rgba(0,114,188,0.2)";
return "rgba(45,140,60,0.10)";
}).attr("opacity", 0.5);
if (gSim) gSim.alpha(0.15).restart();
}
// Compute topic overlap penalty τ for the cascade formula A = Σrᵢ · (1 + 0.06n) · (1 + τ)
// τ = average pairwise cosine similarity of topic weight vectors, capped at 0.5
// Returns 0 for single removal (no change to single-departure behaviour)
function computeTopicTau(employees) {
if (!employees || employees.length < 2) return 0;
// Collect all unique categories across the removed set
const catIndex = {};
let catCount = 0;
employees.forEach(p => {
(p.topic_profile || []).forEach(t => {
const cat = t.category
|| (DATA.topic_categories && DATA.topic_categories[String(t.topic)])
|| null;
if (cat && catIndex[cat] == null) catIndex[cat] = catCount++;
});
});
if (catCount === 0) return 0;
// Build aligned topic weight vectors (0-filled for missing categories)
const vectors = employees.map(p => {
const vec = new Array(catCount).fill(0);
(p.topic_profile || []).forEach(t => {
const cat = t.category
|| (DATA.topic_categories && DATA.topic_categories[String(t.topic)])
|| null;
if (cat != null && catIndex[cat] != null) vec[catIndex[cat]] = t.score || 0;
});
return vec;
});
// Average pairwise cosine similarity
let totalSim = 0, pairs = 0;
for (let i = 0; i < vectors.length; i++) {
for (let j = i + 1; j < vectors.length; j++) {
let dot = 0, magA = 0, magB = 0;
for (let k = 0; k < catCount; k++) {
dot += vectors[i][k] * vectors[j][k];
magA += vectors[i][k] * vectors[i][k];
magB += vectors[j][k] * vectors[j][k];
}
const denom = Math.sqrt(magA) * Math.sqrt(magB);
totalSim += denom > 0 ? dot / denom : 0;
pairs++;
}
}
const raw = pairs > 0 ? totalSim / pairs : 0;
return Math.min(raw, 0.5); // cap at 0.5
}
async function runMultiCascade() {
if (multiQueue.length < 2 || multiSimRunning) return;
multiSimRunning = true;
const btn = document.getElementById("simulateBtn");
if (btn) btn.disabled = true;
// Initialize live risk scores
DATA.people.forEach(p => {
multiOrigRisks[p.person] = p.risk_score;
multiLiveRisks[p.person] = p.risk_score;
});
// Sort queue by risk descending
const sorted = [...multiQueue]
.map(email => DATA.people.find(p => p.person === email))
.filter(Boolean)
.sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0));
const overlay = document.getElementById("cascadeOverlay");
const textEl = document.getElementById("cascadeText");
const personEl = document.getElementById("cascadePerson");
const graphPanel = document.querySelector("#oi-view .panel:nth-child(2)");
let cascadingRisk = 0;
const topicHits = {};
for (let i = 0; i < sorted.length; i++) {
const person = sorted[i];
const name = person.display_name || formatName(person.person);
// Show overlay with animation restart
if (overlay) {
overlay.classList.add("visible");
textEl.textContent = `Removing ${i + 1} of ${sorted.length}...`;
personEl.textContent = name;
personEl.style.animation = "none";
void personEl.offsetWidth;
personEl.style.animation = "cascadePulse 0.6s ease";
}
// Shockwave
if (graphPanel) {
const wave = document.createElement("div");
wave.className = "shockwave";
graphPanel.appendChild(wave);
setTimeout(() => wave.remove(), 1100);
}
// Animate node departure (additive — no restore)
departGraphAdditive(person.person);
// Get person's topic categories
const personCats = [];
(person.topic_profile || []).forEach(t => {
const cat = t.category || (DATA.topic_categories && DATA.topic_categories[String(t.topic)]) || null;
if (cat && !personCats.includes(cat)) personCats.push(cat);
});
personCats.forEach(cat => { topicHits[cat] = (topicHits[cat] || 0) + 1; });
// Boost remaining employees' live risks
DATA.people.forEach(emp => {
if (sorted.find(s => s.person === emp.person)) return;
const empCats = [];
(emp.topic_profile || []).forEach(t => {
const cat = t.category || (DATA.topic_categories && DATA.topic_categories[String(t.topic)]) || null;
if (cat && !empCats.includes(cat)) empCats.push(cat);
});
let boost = 0;
personCats.forEach(cat => { if (empCats.includes(cat)) boost += 0.015 + (i * 0.008); });
if (boost > 0) {
multiLiveRisks[emp.person] = Math.min((multiLiveRisks[emp.person] || emp.risk_score) + boost, 0.95);
const badgeId = "mrisk_" + emp.person.replace(/[^a-z0-9]/gi, "_");
const badge = document.getElementById(badgeId);
if (badge) {
badge.innerHTML = `<span>${(multiLiveRisks[emp.person] * 100).toFixed(0)}</span><span class="risk-badge-sub">/100</span>`;
badge.className = "risk-badge " + riskClass(multiLiveRisks[emp.person]) + " flashing";
setTimeout(() => badge.classList.remove("flashing"), 1100);
}
}
});
cascadingRisk += (person.risk_score || 0) + (i * 0.032);
await new Promise(r => setTimeout(r, 1500));
}
if (overlay) overlay.classList.remove("visible");
multiSimRunning = false;
multiSimDone = true;
renderEmployeeList(document.getElementById("searchBox").value);
const individualSum = sorted.reduce((s, p) => s + (p.risk_score || 0), 0);
// Apply topic overlap penalty τ for multi-removal: A = Σrᵢ · (1 + 0.06n) · (1 + τ)
// Single removal: τ = 0, no change to behaviour
const tau = computeTopicTau(sorted);
const cascadeTotal = Math.min(cascadingRisk * (1 + tau), 1);
const amplification = individualSum > 0
? Math.round(((cascadeTotal - individualSum) / individualSum) * 100)
: 0;
// Persist cascade results so Graph and Report views can consume them
cascadeState = {
removed: sorted.map(p => p.person),
sorted,
liveRisks: { ...multiLiveRisks },
origRisks: { ...multiOrigRisks },
topicHits: { ...topicHits },
individualSum: individualSum * 100,
cascadeTotal: cascadeTotal * 100,
amplification,
tau,
};
gvBuilt = false; // force Graph view rebuild with cascade data
reportBuilt = false; // force Report view rebuild with cascade section
showMultiCombinedImpact(sorted, individualSum * 100, cascadeTotal * 100, amplification, topicHits, tau);
}
function showMultiCombinedImpact(sorted, individualSum, cascadeTotal, amplification, topicHits, tau = 0) {
const panel = document.getElementById("rightPanel");
const namePills = sorted.map(p =>
`<span class="ci-name-pill">${p.display_name || formatName(p.person)}</span>`
).join("");
const indPct = Math.min(individualSum, 100).toFixed(1);
const casPct = Math.min(cascadeTotal, 100).toFixed(1);
const indBar = Math.min(individualSum, 100);
const casBar = Math.min(cascadeTotal, 100);
const allTopics = new Set(Object.keys(topicHits));
const avgHireGap = sorted.reduce((s, p) => s + Math.round((p.external_hire_gap || 0) * 100), 0) / sorted.length;
const avgRecov12 = sorted.reduce((s, p) => {
const rates = p.recovery_rates || [];
return s + (rates[11] != null ? rates[11] * 100 : 0);
}, 0) / sorted.length * 0.7;
const totalPerm = sorted.reduce((s, p) => s + (p.n_perm_loss_categories || 0), 0);
const topicRows = Array.from(allTopics).map(cat => {
const hits = topicHits[cat] || 0;
const st = hits >= 2 ? "LOST" : "PARTIAL";
const stCls = hits >= 2 ? "ti-lost" : "ti-partial";
return `<div class="topic-impact-row"><span class="ti-name">${cat}</span><span class="ti-status ${stCls}">${st}</span></div>`;
}).join("") || `<div style="font-size:11px;color:var(--text-faint);font-family:'JetBrains Mono',monospace">No shared topics identified.</div>`;
const affectedPeople = DATA.people
.filter(p => !sorted.find(s => s.person === p.person))
.map(p => ({
name: p.display_name || formatName(p.person),
original: (multiOrigRisks[p.person] || p.risk_score) * 100,
current: (multiLiveRisks[p.person] || p.risk_score) * 100,
increase: ((multiLiveRisks[p.person] || p.risk_score) - (multiOrigRisks[p.person] || p.risk_score)) * 100,
}))
.filter(p => p.increase > 0.05)
.sort((a, b) => b.increase - a.increase)
.slice(0, 5);
const affectedRows = affectedPeople.map(p =>
`<div class="affected-row">
<div class="affected-name">${p.name}</div>
<div class="affected-change">
<span class="affected-old">${p.original.toFixed(1)}%</span>
<span class="affected-arrow">→</span>
<span class="affected-new">${p.current.toFixed(1)}%</span>
</div>
</div>`
).join("") || `<div style="font-size:11px;color:var(--text-faint);font-family:'JetBrains Mono',monospace">No significant risk increases detected.</div>`;
panel.innerHTML = `
<div style="padding:20px">
<div class="ci-header">Combined Departure Impact</div>
<div class="ci-subheader">Cascading simulation · ${sorted.length} removals</div>
<div class="ci-names">${namePills}</div>
<div class="nonlinear-callout">
<div class="nonlinear-label">Non-linear risk amplification</div>
<div class="nonlinear-row">
<div class="nl-label">Individual sum</div>
<div class="nl-value" style="color:#C49032">${indPct}%</div>
<div class="nl-bar-wrap"><div class="nl-bar"><div class="nl-bar-fill" style="background:#C49032;width:${indBar}%"></div></div></div>
</div>
<div class="nonlinear-row">
<div class="nl-label">Cascading risk</div>
<div class="nl-value" style="color:var(--enron-red)">${casPct}%</div>
<div class="nl-bar-wrap"><div class="nl-bar"><div class="nl-bar-fill" style="background:var(--enron-red);width:${casBar}%"></div></div></div>
</div>
<div class="nonlinear-delta">+${amplification}% amplification from cascading dependencies</div>
${tau > 0 ? `<div class="nonlinear-delta" style="margin-top:4px;opacity:0.75">Topic overlap penalty (τ): +${(tau * 100).toFixed(1)}%</div>` : ''}
</div>
<div class="ci-metrics">
<div class="ci-metric"><div class="m-val" style="color:var(--enron-red)">${allTopics.size}</div><div class="m-label">Topics affected</div></div>
<div class="ci-metric"><div class="m-val" style="color:#C49032">${avgHireGap.toFixed(0)}%</div><div class="m-label">Avg hire gap</div></div>
<div class="ci-metric"><div class="m-val" style="color:var(--enron-red)">${avgRecov12.toFixed(1)}%</div><div class="m-label">Combined recovery</div></div>
<div class="ci-metric"><div class="m-val" style="color:var(--text)">${totalPerm}</div><div class="m-label">Perm losses</div></div>
</div>
<div class="ci-rp-section">
<div class="ci-rp-title">Topic impact breakdown</div>
<div>${topicRows}</div>
</div>
<div class="ci-rp-section">
<div class="ci-rp-title">Most affected remaining employees</div>
<div>${affectedRows}</div>
</div>
<button class="multi-reset-btn" onclick="resetMultiSimulation()">Reset simulation</button>
</div>`;
}
function resetMultiSimulation() {
multiSimDone = false; multiSimRunning = false;
multiOrigRisks = {}; multiLiveRisks = {};
multiQueue = [];
removedId = null;
cascadeState = null; // clear persisted cascade data
gvBuilt = false; // force Graph view to rebuild at baseline
reportBuilt = false; // force Report view to rebuild without cascade section
renderMultiQueue();
renderEmployeeList(document.getElementById("searchBox").value);
buildGraph();
document.getElementById("rightPanel").innerHTML = `
<div class="empty-state">
<div class="empty-state-title">Select an employee</div>
<div class="empty-state-hint">Click any name to simulate their departure</div>
</div>`;
}
// Title-first dept assignment shared by both graph views
function titleToDept(role) {
const t = (role || "").toLowerCase();
if (/\b(ceo|president|coo|evp|chief|chairman|executive)\b/.test(t)) return "Executive";
if (/\bvp\b/.test(t)) return "Executive";
if (/\b(legal|counsel|attorney)\b/.test(t)) return "Legal";
if (/\b(trader|trading)\b/.test(t)) return "Trading";
if (/\b(analyst|associate|coordinator|specialist)\b/.test(t)) return "Operations";
if (/\b(assistant|secretary|administrative)\b/.test(t)) return "Administration";
return null; // fall back to role_category
}
// ── D3 graph ───────────────────────────────────────────────────────────────────
// buildGraph() creates a persistent simulation. Departure animation is handled
// by departGraph() which animates on existing elements without rebuilding.
function buildGraph() {
const svg = d3.select("#graphSvg");
svg.selectAll("*").remove();
if (gSim) { gSim.stop(); gSim = null; }
const c = document.getElementById("graphContainer");
const w = c.clientWidth, h = c.clientHeight;
// Static copies — filter out nodes with <3 connections within top-50 subgraph
const deptLookupOI = {};
DATA.people.forEach(p => {
const fromTitle = titleToDept(p.role);
deptLookupOI[p.person] = fromTitle || (p.role_category || "Administration");
});
const rawLinksOI = g50edges.map(e => ({ source: e.source, target: e.target, weight: e.weight, wn: e.wn }));
const degOI = {};
rawLinksOI.forEach(l => { degOI[l.source] = (degOI[l.source] || 0) + 1; degOI[l.target] = (degOI[l.target] || 0) + 1; });
// Top-20 by risk_score always appear regardless of degree (e.g., Pete Davis)
const top20OI = new Set([...g50nodes].sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0)).slice(0, 20).map(n => n.id));
const inclOI = new Set(g50nodes.filter(n => (degOI[n.id] || 0) >= 3 || top20OI.has(n.id)).map(n => n.id));
const nodes = g50nodes.filter(n => inclOI.has(n.id)).map(n => ({
id: n.id,
display_name: n.display_name,
risk_score: n.risk_score,
weighted_degree: n.weighted_degree,
degree: n.degree,
dept: deptLookupOI[n.id] || "Administration",
}));
const links = rawLinksOI.filter(l => inclOI.has(l.source) && inclOI.has(l.target));
const wMaxOI = d3.max(links, l => l.weight) || 1;
const edgeWOI = d => 1 + (d.weight / wMaxOI) * 2;
// ── SVG defs ──
const defs = svg.append("defs");
// White pulse glow (for pulse overlay circles)
const pulseF = defs.append("filter").attr("id","pulse-glow").attr("x","-100%").attr("y","-100%").attr("width","300%").attr("height","300%");
pulseF.append("feGaussianBlur").attr("in","SourceGraphic").attr("stdDeviation","6").attr("result","blur");
pulseF.append("feFlood").attr("flood-color","#FFFFFF").attr("flood-opacity","0.6").attr("result","white");
pulseF.append("feComposite").attr("in","white").attr("in2","blur").attr("operator","in").attr("result","glow");
const pMerge = pulseF.append("feMerge");
pMerge.append("feMergeNode").attr("in","glow");
pMerge.append("feMergeNode").attr("in","SourceGraphic");
// Per-tier node glow
[["high","#D4342E","0.4"],["mid","#0072BC","0.3"],["low","#2D8C3C","0.25"]].forEach(([tier,color,opacity]) => {
const f = defs.append("filter").attr("id",`glow-${tier}`).attr("x","-100%").attr("y","-100%").attr("width","300%").attr("height","300%");
f.append("feGaussianBlur").attr("in","SourceGraphic").attr("stdDeviation","4").attr("result","blur");
f.append("feFlood").attr("flood-color",color).attr("flood-opacity",opacity).attr("result","color");
f.append("feComposite").attr("in","color").attr("in2","blur").attr("operator","in").attr("result","glow");
const m = f.append("feMerge");
m.append("feMergeNode").attr("in","glow");
m.append("feMergeNode").attr("in","SourceGraphic");
});
const g = svg.append("g");
svg.call(d3.zoom().scaleExtent([0.3, 4]).on("zoom", ev => g.attr("transform", ev.transform)));
// ── Dept hull layer (behind everything) ──
const deptGroupsOI = {};
nodes.forEach(n => { (deptGroupsOI[n.dept] = deptGroupsOI[n.dept] || []).push(n); });
const hullDeptsOI = Object.keys(deptGroupsOI).filter(d => deptGroupsOI[d].length >= 3);
const clusterDeptsOI = Object.keys(deptGroupsOI).filter(d => deptGroupsOI[d].length >= 2);
const gDeptOI = g.append("g").attr("class", "gvn-dept-layer");
const hullPathsOI = {}, hullLabelsOI = {};
hullDeptsOI.forEach(dept => {
const hex = GV_DEPT_COLORS[dept] || "#48484A";
hullPathsOI[dept] = gDeptOI.append("path").attr("fill", hex).attr("opacity", 0.07)
.attr("stroke", "none").style("pointer-events", "none");
hullLabelsOI[dept] = gDeptOI.append("text").attr("fill", hex).attr("font-size", "9px")
.attr("font-family", "'JetBrains Mono',monospace").attr("font-weight", "600")
.attr("opacity", 0.35).attr("text-anchor", "middle").style("pointer-events", "none")
.text(dept.toUpperCase());
});
function updateHullsOI() {
hullDeptsOI.forEach(dept => {
const pts = deptGroupsOI[dept].filter(n => n.x != null).map(n => [n.x, n.y]);
if (pts.length < 3) return;
const hull = d3.polygonHull(pts);
if (!hull) return;
const cx = d3.mean(pts, p => p[0]), cy = d3.mean(pts, p => p[1]);
const pad = 28;
const expanded = hull.map(([x, y]) => {
const dx = x - cx, dy = y - cy, len = Math.sqrt(dx*dx + dy*dy) || 1;
return [x + (dx/len)*pad, y + (dy/len)*pad];
});
hullPathsOI[dept].attr("d", "M" + expanded.map(p => p.join(",")).join("L") + "Z");
hullLabelsOI[dept].attr("x", cx).attr("y", cy - 32);
});
}
// ── Edges ──
const tooltip = document.getElementById("graphTooltip");
gLe = g.selectAll(".edge-line")
.data(links).enter().append("line")
.attr("class", "edge-line")
.attr("stroke", "rgba(0,114,188,0.15)")
.attr("stroke-width", edgeWOI)
.on("mouseover", (ev, d) => {
const sn = (d.source.display_name || lastName(d.source.id || d.source)).split(" ").pop();
const tn = (d.target.display_name || lastName(d.target.id || d.target)).split(" ").pop();
tooltip.textContent = `${sn} \u2194 ${tn} \u00b7 ${Math.round(d.weight).toLocaleString()} emails`;
tooltip.style.display = "block";
d3.select(ev.currentTarget).attr("stroke", "rgba(0,114,188,0.6)").attr("stroke-width", edgeWOI(d) + 1);
})
.on("mousemove", ev => {
tooltip.style.left = (ev.clientX + 14) + "px";
tooltip.style.top = (ev.clientY - 10) + "px";
})
.on("mouseout", (ev, d) => {
tooltip.style.display = "none";
d3.select(ev.currentTarget).attr("stroke", "rgba(0,114,188,0.15)").attr("stroke-width", edgeWOI(d));
});
// ── Edge hit-area overlay (wide transparent lines for easier hover targeting) ──
g.selectAll(".edge-hit")
.data(links).enter().append("line")
.attr("class", "edge-hit")
.attr("stroke", "transparent")
.attr("stroke-width", 10)
.style("pointer-events", "stroke")
.on("mouseover", (ev, d) => {
const sn = (d.source.display_name || lastName(d.source.id || d.source)).split(" ").pop();
const tn = (d.target.display_name || lastName(d.target.id || d.target)).split(" ").pop();
tooltip.textContent = `${sn} \u2194 ${tn} \u00b7 ${Math.round(d.weight).toLocaleString()} emails`;
tooltip.style.display = "block";
gLe.filter(l => l === d).attr("stroke", "rgba(0,114,188,0.6)").attr("stroke-width", edgeWOI(d) + 1);
})
.on("mousemove", ev => {
tooltip.style.left = (ev.clientX + 14) + "px";
tooltip.style.top = (ev.clientY - 10) + "px";
})
.on("mouseout", (ev, d) => {
tooltip.style.display = "none";
gLe.filter(l => l === d).attr("stroke", "rgba(0,114,188,0.15)").attr("stroke-width", edgeWOI(d));
});
// ── Glow halos ──
gGc = g.selectAll(".node-glow")
.data(nodes).enter().append("circle")
.attr("class", "node-glow")
.attr("r", d => 12 + d.risk_score * 24)
.attr("fill", d => {
if (d.risk_score >= 0.40) return "rgba(212,52,46,0.3)";
if (d.risk_score >= 0.15) return "rgba(0,114,188,0.2)";
return "rgba(45,140,60,0.10)";
})
.attr("opacity", 0.5)
.style("pointer-events", "none");
// ── Pulse overlay circles (white, initially invisible) ──
gPc = g.selectAll(".pulse-circle")
.data(nodes).enter().append("circle")
.attr("class", "pulse-circle")
.attr("r", 0).attr("fill", "white").attr("opacity", 0)
.style("pointer-events", "none");
// ── Node circles ──
gNe = g.selectAll(".node-circle")
.data(nodes).enter().append("circle")
.attr("class", "node-circle")
.attr("r", d => 8 + d.risk_score * 20)
.attr("fill", d => nodeColor(d.risk_score))
.attr("stroke", "#0A0A0A").attr("stroke-width", 2)
.attr("filter", d => nodeGlowFilter(d.risk_score))
.on("mouseover", (ev, d) => {
tooltip.textContent = `${d.display_name || lastName(d.id)} · ${(d.risk_score*100).toFixed(0)} risk`;
tooltip.style.display = "block";
if (d.quadrant === "Low Priority" && gLa) gLa.filter(d2 => d2.id === d.id).attr("display", null);
})
.on("mousemove", ev => {
tooltip.style.left = (ev.clientX + 14) + "px";
tooltip.style.top = (ev.clientY - 10) + "px";
})
.on("mouseout", (ev, d) => {
tooltip.style.display = "none";
if (d.quadrant === "Low Priority" && gLa) gLa.filter(d2 => d2.id === d.id).attr("display", "none");
})
.on("click", (ev, d) => { ev.stopPropagation(); tooltip.style.display = "none"; simulateDeparture(d.id); })
.style("cursor", "pointer");
// ── Labels ──
gLa = g.selectAll(".node-label")
.data(nodes).enter().append("text")
.attr("class", "node-label")
.attr("dy", d => (8 + d.risk_score * 20) + 14)
.text(d => d.display_name ? d.display_name.split(" ").pop() : lastName(d.id))
.attr("display", d => d.quadrant === "Low Priority" ? "none" : null);
// ── Force simulation ──
// Clustering force (mirrors Network Explorer)
const clusterForceOI = alpha => {
const centroids = {};
clusterDeptsOI.forEach(dept => {
const grp = deptGroupsOI[dept].filter(n => n.x != null);
if (!grp.length) return;
centroids[dept] = { x: d3.mean(grp, n => n.x), y: d3.mean(grp, n => n.y) };
});
nodes.forEach(n => {
const c = centroids[n.dept];
if (!c) return;
n.vx = (n.vx || 0) + (c.x - n.x) * 0.04 * alpha;
n.vy = (n.vy || 0) + (c.y - n.y) * 0.04 * alpha;
});