Progress to 2030
As we move boldly forward with our plan, achieving our strategic imperatives will be fundamental to our future success as a next-generation land-grant university.
We know providing transparent, up-to-date reporting on progress toward our imperative goals is essential to provide accountability on our commitment toward Kansas and the world.
Chart our progress on the 10 university-wide imperatives using the dashboard below:
Learn more about our strategic imperatives and our data methodology.
Impact in action
In addition to our 10 university-wide imperatives, we use partnerships, investments and initiatives to strategically target ideas, people and projects that exemplify what it means to be a K-Stater.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>K-State Open-Call RFP Selection Funnel</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<style>
:root { --ink:#1a1a1a; --muted:#5a5a5a; --line:#e2e2e2; --bg:#ffffff;
--type-step: 7px; }
* { box-sizing: border-box; }
html, body { width:100%; height:100%; }
body { margin:0; background:var(--bg); color:var(--ink);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.wrap { max-width: none; margin: 0; padding: 20px 24px 20px;
height: 100vh; display: flex; flex-direction: column; }
.wrap > :not(#chart) { flex: none; }
h1 { font-size: calc(20px + var(--type-step)); font-weight: 650; color: #512888; margin: 0 0 4px; }
.subtitle { color: var(--muted); font-size: calc(13.5px + var(--type-step));
margin: 0 0 14px; max-width: 74ch; }
.controls { display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:6px; }
.controls label { font-size: calc(12.5px + var(--type-step)); color: var(--muted); font-weight:600; }
select { font: inherit; font-size: calc(13px + var(--type-step)); padding: 5px 8px;
border:1px solid #bbb; border-radius: 6px; background:#fff; }
#summary { font-size: calc(13px + var(--type-step)); color: var(--ink); font-weight:600; }
#chart { width:100%; flex: 1; overflow: hidden; min-height: 900px; margin-top: 8px; }
#chart svg { display:block; }
.link { transition: stroke-opacity .12s; cursor: default; }
.link:hover { stroke-opacity: 0.95 !important; }
.node-rect { cursor: ns-resize; }
.node.clickable, .node.clickable .node-rect { cursor: pointer; }
.node.clickable:hover .node-name,
.node.clickable:hover .node-sub { text-decoration: underline; }
.node-name { font-weight:600; font-size: calc(12px + var(--type-step)); fill:var(--ink);
paint-order:stroke; stroke:#fff; stroke-width:3px; stroke-linejoin:round; }
.node-sub { font-weight:400; font-size: calc(10px + var(--type-step)); fill:#333333;
paint-order:stroke; stroke:#fff; stroke-width:3px; stroke-linejoin:round; }
.band-label{ font-weight:600; font-size: calc(12.5px + var(--type-step)); fill:#333; }
footer { margin-top: 14px; border-top:1px solid var(--line); padding-top: 12px;
display:flex; justify-content:space-between; align-items:flex-start;
gap:18px; flex-wrap:wrap; }
#legend { display:flex; gap:16px; flex-wrap:wrap; align-items:center; }
.legend-item { display:inline-flex; align-items:center; gap:6px;
font-size: calc(12.5px + var(--type-step)); }
.swatch { width:13px; height:13px; border-radius:3px; display:inline-block; }
#legend-caption { color:var(--muted); font-size: calc(12px + var(--type-step));
margin-top:6px; max-width:70ch; }
#scale-note { color:var(--muted); font-size: calc(12px + var(--type-step));
text-align:right; white-space:nowrap; }
#tip { position:fixed; pointer-events:auto; display:none; z-index:20; max-width:340px;
background:#26232b; color:#fff; font-size: calc(12px + var(--type-step)); line-height:1.4;
padding:8px 10px; border-radius:7px; box-shadow:0 4px 16px rgba(0,0,0,.28); }
#tip a { color:#C8A2C8; }
#tip.linked { cursor: pointer; }
/* hide the CMS DirectEdit "Updated" stamp injected into this standalone file */
a[href*="omniupdate.com"] { display: none !important; }
</style>
</head>
<body>
<div class="wrap">
<h1>K-State Open-Call RFP Selection Funnel</h1>
<p class="subtitle">See the flow of proposals from initial applicants to selected finalists</p>
<div class="controls">
<label for="mode">View</label>
<select id="mode"></select>
<span id="summary"></span>
</div>
<div id="chart"></div>
<footer>
<div>
<div id="legend"></div>
<div id="legend-caption"></div>
</div>
<div id="scale-note"></div>
</footer>
</div>
<div id="tip"></div>
<script>
//__JS_BEGIN__
const DATA = {"views": {"AI": {"nodes": [{"node_id": "AI::total", "col": 0, "order": 0, "role": "total", "label": "All proposals", "sublabel": "27 proposals · $3.67M", "count": 27, "dollars": 3671986, "rfp": "AI", "color": "#8C71B0", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::not_advanced", "col": 1, "order": 0, "role": "not_advanced", "label": "Not selected", "sublabel": "20 proposals · $2.68M", "count": 20, "dollars": 2684964, "rfp": "AI", "color": "#6F4D9C", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::advanced", "col": 1, "order": 1, "role": "advanced", "label": "", "sublabel": "", "count": 7, "dollars": 987022, "rfp": "AI", "color": "#6F4D9C", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::semi_not_selected", "col": 2, "order": 0, "role": "semi_not_selected", "label": "Semi-finalists, not selected", "sublabel": "1 proposal · $93,000", "count": 1, "dollars": 93000, "rfp": "AI", "color": "#512888", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::finalists", "col": 2, "order": 1, "role": "finalists", "label": "Finalists", "sublabel": "6 proposals · $894,022", "count": 6, "dollars": 894022, "rfp": "AI", "color": "#512888", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::fin::Extending K-State Student Success Initiatives via AI", "col": 3, "order": 0, "role": "finalist", "label": "Extending Student Success Initiatives via AI", "sublabel": "$209,022", "count": 1, "dollars": 209022, "rfp": "AI", "color": "#3F1F6A", "title": "Extending K-State Student Success Initiatives via AI", "dept": "DASSA", "contact": "Bill Watts", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "col": 3, "order": 1, "role": "finalist", "label": "Agricultural AI, Data and Digital Twins", "sublabel": "$180,000", "count": 1, "dollars": 180000, "rfp": "AI", "color": "#3F1F6A", "title": "Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "dept": "Biological and Agricultural Engineering", "contact": "Ajay Sharda", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::Regional Expansion of the K-State AI Symposium", "col": 3, "order": 2, "role": "finalist", "label": "Expansion of the K-State AI Symposium", "sublabel": "$143,000", "count": 1, "dollars": 143000, "rfp": "AI", "color": "#3F1F6A", "title": "Regional Expansion of the K-State AI Symposium", "dept": "Modern Languages", "contact": "Raelynne Hale", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::AI and Next-Gen Management in Business Initiative", "col": 3, "order": 3, "role": "finalist", "label": "AI and Next-Gen Management in Business", "sublabel": "$137,000", "count": 1, "dollars": 137000, "rfp": "AI", "color": "#3F1F6A", "title": "AI and Next-Gen Management in Business Initiative", "dept": "Department of Management", "contact": "Hansin Bilgili", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::Embedding Modern Computational Methods in High-Enrollment STEM Classes", "col": 3, "order": 4, "role": "finalist", "label": "Computational Methods in STEM", "sublabel": "$125,000", "count": 1, "dollars": 125000, "rfp": "AI", "color": "#3F1F6A", "title": "Embedding Modern Computational Methods in High-Enrollment STEM Classes", "dept": "Mathematics, Physics", "contact": "Andrew Bennett", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "col": 3, "order": 5, "role": "finalist", "label": "AI for high-precision agriculture", "sublabel": "$100,000", "count": 1, "dollars": 100000, "rfp": "AI", "color": "#3F1F6A", "title": "Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "dept": "Computer Science, Biological and Agricultural Engineering", "contact": "Eugene Vasserman", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}], "links": [{"source": "AI::total", "target": "AI::not_advanced", "value": 2684964, "count": 20, "rfp": "AI", "color": "#512888", "link_group": "total>not_advanced"}, {"source": "AI::total", "target": "AI::advanced", "value": 987022, "count": 7, "rfp": "AI", "color": "#512888", "link_group": "total>advanced"}, {"source": "AI::advanced", "target": "AI::semi_not_selected", "value": 93000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "advanced>semi"}, {"source": "AI::advanced", "target": "AI::finalists", "value": 894022, "count": 6, "rfp": "AI", "color": "#512888", "link_group": "advanced>finalists"}, {"source": "AI::finalists", "target": "AI::fin::Extending K-State Student Success Initiatives via AI", "value": 209022, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "value": 180000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::Regional Expansion of the K-State AI Symposium", "value": 143000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::AI and Next-Gen Management in Business Initiative", "value": 137000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::Embedding Modern Computational Methods in High-Enrollment STEM Classes", "value": 125000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "value": 100000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}]}, "One K-State": {"nodes": [{"node_id": "One K-State::total", "col": 0, "order": 0, "role": "total", "label": "All proposals", "sublabel": "19 proposals · $2.57M", "count": 19, "dollars": 2572032, "rfp": "One K-State", "color": "#DFC18D", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::not_advanced", "col": 1, "order": 0, "role": "not_advanced", "label": "Not selected", "sublabel": "9 proposals · $883,078", "count": 9, "dollars": 883078, "rfp": "One K-State", "color": "#D6B16F", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::advanced", "col": 1, "order": 1, "role": "advanced", "label": "", "sublabel": "", "count": 10, "dollars": 1688954, "rfp": "One K-State", "color": "#D6B16F", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::semi_not_selected", "col": 2, "order": 0, "role": "semi_not_selected", "label": "Redirected to other sources", "sublabel": "6 proposals · $1.37M", "count": 6, "dollars": 1368454, "rfp": "One K-State", "color": "#CEA152", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::finalists", "col": 2, "order": 1, "role": "finalists", "label": "Finalists", "sublabel": "4 proposals · $320,500", "count": 4, "dollars": 320500, "rfp": "One K-State", "color": "#CEA152", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::fin::Fermentation Innovation Hub at K-State Olathe", "col": 3, "order": 0, "role": "finalist", "label": "Fermentation Innovation at K-State Olathe", "sublabel": "$175,000", "count": 1, "dollars": 175000, "rfp": "One K-State", "color": "#A17E40", "title": "Fermentation Innovation Hub at K-State Olathe", "dept": "College of Applied and Professional Studies", "contact": "Darrin Smith", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "One K-State::fin::Faculty-Student Connections for Retention and Well-being", "col": 3, "order": 1, "role": "finalist", "label": "Faculty-Student Connections for Retention and Well-being", "sublabel": "$88,000", "count": 1, "dollars": 88000, "rfp": "One K-State", "color": "#A17E40", "title": "Faculty-Student Connections for Retention and Well-being", "dept": "Dean's Office in the College of Arts & Sciences", "contact": "Kimathi Choma", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "One K-State::fin::Veteran Affairs Work Study Enhancement Proposal", "col": 3, "order": 2, "role": "finalist", "label": "Veteran Affairs Work Study Enhancement", "sublabel": "$40,000", "count": 1, "dollars": 40000, "rfp": "One K-State", "color": "#A17E40", "title": "Veteran Affairs Work Study Enhancement Proposal", "dept": "Division of Academic Success and Student Affairs (DASSA)", "contact": "(Pending)", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "One K-State::fin::The Wildcat Well-being Challenge", "col": 3, "order": 3, "role": "finalist", "label": "The Wildcat Well-being Challenge", "sublabel": "$17,500", "count": 1, "dollars": 17500, "rfp": "One K-State", "color": "#A17E40", "title": "The Wildcat Well-being Challenge", "dept": "Student Well-being (DASSA)", "contact": "Austin Drake", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}], "links": [{"source": "One K-State::total", "target": "One K-State::not_advanced", "value": 883078, "count": 9, "rfp": "One K-State", "color": "#CEA152", "link_group": "total>not_advanced"}, {"source": "One K-State::total", "target": "One K-State::advanced", "value": 1688954, "count": 10, "rfp": "One K-State", "color": "#CEA152", "link_group": "total>advanced"}, {"source": "One K-State::advanced", "target": "One K-State::semi_not_selected", "value": 1368454, "count": 6, "rfp": "One K-State", "color": "#CEA152", "link_group": "advanced>semi"}, {"source": "One K-State::advanced", "target": "One K-State::finalists", "value": 320500, "count": 4, "rfp": "One K-State", "color": "#CEA152", "link_group": "advanced>finalists"}, {"source": "One K-State::finalists", "target": "One K-State::fin::Fermentation Innovation Hub at K-State Olathe", "value": 175000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "One K-State::finalists", "target": "One K-State::fin::Faculty-Student Connections for Retention and Well-being", "value": 88000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "One K-State::finalists", "target": "One K-State::fin::Veteran Affairs Work Study Enhancement Proposal", "value": 40000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "One K-State::finalists", "target": "One K-State::fin::The Wildcat Well-being Challenge", "value": 17500, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}]}, "Sustainability": {"nodes": [{"node_id": "Sustainability::total", "col": 0, "order": 0, "role": "total", "label": "All proposals", "sublabel": "29 proposals · $11.32M", "count": 29, "dollars": 11318054, "rfp": "Sustainability", "color": "#C09C8B", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::not_advanced", "col": 1, "order": 0, "role": "not_advanced", "label": "Not selected", "sublabel": "24 proposals · $9.09M", "count": 24, "dollars": 9088054, "rfp": "Sustainability", "color": "#AF826D", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::advanced", "col": 1, "order": 1, "role": "advanced", "label": "", "sublabel": "", "count": 5, "dollars": 2230000, "rfp": "Sustainability", "color": "#AF826D", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::semi_not_selected", "col": 2, "order": 0, "role": "semi_not_selected", "label": "Redirected to OVPR", "sublabel": "4 proposals · $1.73M", "count": 4, "dollars": 1730000, "rfp": "Sustainability", "color": "#9F694F", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::finalists", "col": 2, "order": 1, "role": "finalists", "label": "Finalists", "sublabel": "1 proposal · $500,000", "count": 1, "dollars": 500000, "rfp": "Sustainability", "color": "#9F694F", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::fin::Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "col": 3, "order": 0, "role": "finalist", "label": "Growing Sustainable Food Systems: Research, Learning, and Campus with Community Engagement", "sublabel": "$500,000", "count": 1, "dollars": 500000, "rfp": "Sustainability", "color": "#7C523E", "title": "Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "dept": "Horticulture and Natural Resources, Agricultural Economics, Agronomy, Sociology, Anthropology and Social Work, Social Transformation Studies, School of Health Sciences, College of Applied and Professional Studies at K-State Olathe, Animal Science, Housing and Dining Services, Ag Programs Office", "contact": "Bizhen Hu", "weblink": "https://www.k-state.edu/news/articles/2026/07/one-kstate-fund-sustainable-campus-food-system.html"}], "links": [{"source": "Sustainability::total", "target": "Sustainability::not_advanced", "value": 9088054, "count": 24, "rfp": "Sustainability", "color": "#9F694F", "link_group": "total>not_advanced"}, {"source": "Sustainability::total", "target": "Sustainability::advanced", "value": 2230000, "count": 5, "rfp": "Sustainability", "color": "#9F694F", "link_group": "total>advanced"}, {"source": "Sustainability::advanced", "target": "Sustainability::semi_not_selected", "value": 1730000, "count": 4, "rfp": "Sustainability", "color": "#9F694F", "link_group": "advanced>semi"}, {"source": "Sustainability::advanced", "target": "Sustainability::finalists", "value": 500000, "count": 1, "rfp": "Sustainability", "color": "#9F694F", "link_group": "advanced>finalists"}, {"source": "Sustainability::finalists", "target": "Sustainability::fin::Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "value": 500000, "count": 1, "rfp": "Sustainability", "color": "#9F694F", "link_group": "finalists>individual"}]}, "pooled": {"nodes": [{"node_id": "pooled::total", "col": 0, "order": 0, "role": "total", "label": "All proposals", "sublabel": "75 proposals · $17.56M", "count": 75, "dollars": 17562072, "rfp": "", "color": "#C5C5C5", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::not_advanced", "col": 1, "order": 0, "role": "not_advanced", "label": "Not selected", "sublabel": "53 proposals · $12.66M", "count": 53, "dollars": 12656096, "rfp": "", "color": "#B6B6B6", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::advanced", "col": 1, "order": 1, "role": "advanced", "label": "", "sublabel": "", "count": 22, "dollars": 4905976, "rfp": "", "color": "#B6B6B6", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::semi_not_selected", "col": 2, "order": 0, "role": "semi_not_selected", "label": "Semi-finalists, not selected", "sublabel": "11 proposals · $3.19M", "count": 11, "dollars": 3191454, "rfp": "", "color": "#A7A7A7", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::finalists", "col": 2, "order": 1, "role": "finalists", "label": "Finalists", "sublabel": "11 proposals · $1.71M", "count": 11, "dollars": 1714522, "rfp": "", "color": "#A7A7A7", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::fin::Extending K-State Student Success Initiatives via AI", "col": 3, "order": 0, "role": "finalist", "label": "Extending Student Success Initiatives via AI", "sublabel": "$209,022", "count": 1, "dollars": 209022, "rfp": "AI", "color": "#3F1F6A", "title": "Extending K-State Student Success Initiatives via AI", "dept": "DASSA", "contact": "Bill Watts", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "col": 3, "order": 1, "role": "finalist", "label": "Agricultural AI, Data and Digital Twins", "sublabel": "$180,000", "count": 1, "dollars": 180000, "rfp": "AI", "color": "#3F1F6A", "title": "Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "dept": "Biological and Agricultural Engineering", "contact": "Ajay Sharda", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Regional Expansion of the K-State AI Symposium", "col": 3, "order": 2, "role": "finalist", "label": "Expansion of the K-State AI Symposium", "sublabel": "$143,000", "count": 1, "dollars": 143000, "rfp": "AI", "color": "#3F1F6A", "title": "Regional Expansion of the K-State AI Symposium", "dept": "Modern Languages", "contact": "Raelynne Hale", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::AI and Next-Gen Management in Business Initiative", "col": 3, "order": 3, "role": "finalist", "label": "AI and Next-Gen Management in Business", "sublabel": "$137,000", "count": 1, "dollars": 137000, "rfp": "AI", "color": "#3F1F6A", "title": "AI and Next-Gen Management in Business Initiative", "dept": "Department of Management", "contact": "Hansin Bilgili", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Embedding Modern Computational Methods in High-Enrollment STEM Classes", "col": 3, "order": 4, "role": "finalist", "label": "Computational Methods in STEM", "sublabel": "$125,000", "count": 1, "dollars": 125000, "rfp": "AI", "color": "#3F1F6A", "title": "Embedding Modern Computational Methods in High-Enrollment STEM Classes", "dept": "Mathematics, Physics", "contact": "Andrew Bennett", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "col": 3, "order": 5, "role": "finalist", "label": "AI for high-precision agriculture", "sublabel": "$100,000", "count": 1, "dollars": 100000, "rfp": "AI", "color": "#3F1F6A", "title": "Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "dept": "Computer Science, Biological and Agricultural Engineering", "contact": "Eugene Vasserman", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Fermentation Innovation Hub at K-State Olathe", "col": 3, "order": 6, "role": "finalist", "label": "Fermentation Innovation at K-State Olathe", "sublabel": "$175,000", "count": 1, "dollars": 175000, "rfp": "One K-State", "color": "#A17E40", "title": "Fermentation Innovation Hub at K-State Olathe", "dept": "College of Applied and Professional Studies", "contact": "Darrin Smith", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "pooled::fin::Faculty-Student Connections for Retention and Well-being", "col": 3, "order": 7, "role": "finalist", "label": "Faculty-Student Connections for Retention and Well-being", "sublabel": "$88,000", "count": 1, "dollars": 88000, "rfp": "One K-State", "color": "#A17E40", "title": "Faculty-Student Connections for Retention and Well-being", "dept": "Dean's Office in the College of Arts & Sciences", "contact": "Kimathi Choma", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "pooled::fin::Veteran Affairs Work Study Enhancement Proposal", "col": 3, "order": 8, "role": "finalist", "label": "Veteran Affairs Work Study Enhancement", "sublabel": "$40,000", "count": 1, "dollars": 40000, "rfp": "One K-State", "color": "#A17E40", "title": "Veteran Affairs Work Study Enhancement Proposal", "dept": "Division of Academic Success and Student Affairs (DASSA)", "contact": "(Pending)", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "pooled::fin::The Wildcat Well-being Challenge", "col": 3, "order": 9, "role": "finalist", "label": "The Wildcat Well-being Challenge", "sublabel": "$17,500", "count": 1, "dollars": 17500, "rfp": "One K-State", "color": "#A17E40", "title": "The Wildcat Well-being Challenge", "dept": "Student Well-being (DASSA)", "contact": "Austin Drake", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "pooled::fin::Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "col": 3, "order": 10, "role": "finalist", "label": "Growing Sustainable Food Systems: Research, Learning, and Campus with Community Engagement", "sublabel": "$500,000", "count": 1, "dollars": 500000, "rfp": "Sustainability", "color": "#7C523E", "title": "Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "dept": "Horticulture and Natural Resources, Agricultural Economics, Agronomy, Sociology, Anthropology and Social Work, Social Transformation Studies, School of Health Sciences, College of Applied and Professional Studies at K-State Olathe, Animal Science, Housing and Dining Services, Ag Programs Office", "contact": "Bizhen Hu", "weblink": "https://www.k-state.edu/news/articles/2026/07/one-kstate-fund-sustainable-campus-food-system.html"}], "links": [{"source": "pooled::total", "target": "pooled::not_advanced", "value": 2684964, "count": 20, "rfp": "AI", "color": "#512888", "link_group": "total>not_advanced"}, {"source": "pooled::total", "target": "pooled::advanced", "value": 987022, "count": 7, "rfp": "AI", "color": "#512888", "link_group": "total>advanced"}, {"source": "pooled::advanced", "target": "pooled::semi_not_selected", "value": 93000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "advanced>semi"}, {"source": "pooled::advanced", "target": "pooled::finalists", "value": 894022, "count": 6, "rfp": "AI", "color": "#512888", "link_group": "advanced>finalists"}, {"source": "pooled::total", "target": "pooled::not_advanced", "value": 883078, "count": 9, "rfp": "One K-State", "color": "#CEA152", "link_group": "total>not_advanced"}, {"source": "pooled::total", "target": "pooled::advanced", "value": 1688954, "count": 10, "rfp": "One K-State", "color": "#CEA152", "link_group": "total>advanced"}, {"source": "pooled::advanced", "target": "pooled::semi_not_selected", "value": 1368454, "count": 6, "rfp": "One K-State", "color": "#CEA152", "link_group": "advanced>semi"}, {"source": "pooled::advanced", "target": "pooled::finalists", "value": 320500, "count": 4, "rfp": "One K-State", "color": "#CEA152", "link_group": "advanced>finalists"}, {"source": "pooled::total", "target": "pooled::not_advanced", "value": 9088054, "count": 24, "rfp": "Sustainability", "color": "#9F694F", "link_group": "total>not_advanced"}, {"source": "pooled::total", "target": "pooled::advanced", "value": 2230000, "count": 5, "rfp": "Sustainability", "color": "#9F694F", "link_group": "total>advanced"}, {"source": "pooled::advanced", "target": "pooled::semi_not_selected", "value": 1730000, "count": 4, "rfp": "Sustainability", "color": "#9F694F", "link_group": "advanced>semi"}, {"source": "pooled::advanced", "target": "pooled::finalists", "value": 500000, "count": 1, "rfp": "Sustainability", "color": "#9F694F", "link_group": "advanced>finalists"}, {"source": "pooled::finalists", "target": "pooled::fin::Extending K-State Student Success Initiatives via AI", "value": 209022, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "value": 180000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Regional Expansion of the K-State AI Symposium", "value": 143000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::AI and Next-Gen Management in Business Initiative", "value": 137000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Embedding Modern Computational Methods in High-Enrollment STEM Classes", "value": 125000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "value": 100000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Fermentation Innovation Hub at K-State Olathe", "value": 175000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Faculty-Student Connections for Retention and Well-being", "value": 88000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Veteran Affairs Work Study Enhancement Proposal", "value": 40000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::The Wildcat Well-being Challenge", "value": 17500, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "value": 500000, "count": 1, "rfp": "Sustainability", "color": "#9F694F", "link_group": "finalists>individual"}]}}, "meta": {"diagram_type": "open-call RFP selection funnel (weighted by requested dollars)", "version": "v16", "generated": "2026-08-28", "source_csv": "project/data/raw/open_calls_selection_summary.csv", "width_semantics": "Ribbon width = requested dollars; node labels also carry proposal counts.", "colour_encoding": "Open_call (RFP)", "palette_source": "pres_export_palette.py (root: sankey_2026 pres_export_palette.R)", "modes": "pooled (default) | side by side | AI | One K-State | Sustainability", "default_mode": "pooled", "sidebyside_note": "composed at render time by stacking the AI, One K-State, Sustainability views", "pooled_link_note": "aggregate stage nodes merged; links split per RFP (three streams); aggregate-node fill neutral grey pending Phase 3", "dropoff_colour_note": "drop-off flows (total>not_advanced, advanced>semi) and their terminal 'not selected' nodes now use the full RFP colour, same as the survivors; pooled aggregate nodes stay neutral grey (they merge three RFPs)", "node_layer_ramp_note": "node fills step in lightness by layer: col 0-1 lighter, col 2 the base colour, col 3 darker (links are not ramped)", "node_align_note": "terminal nodes have no outgoing link — use nodeAlign(d3.sankeyLeft) or authored col", "layer_trap_note": "read the authored column off `col`, never d3's `.layer`", "legend_caption": "Ribbon width shows requested dollars. Node labels show proposal count and total requested.", "finalist_count": "11", "grand_total": "75 proposals, $17,562,072", "summary_pooled": "All three — 75 proposals, $17.56M requested, $1.71M awarded", "summary_AI": "AI — 27 proposals, $3.67M requested, $894,022 awarded", "colour_AI": "#512888", "summary_One K-State": "One K-State — 19 proposals, $2.57M requested, $320,500 awarded", "colour_One K-State": "#CEA152", "summary_Sustainability": "Sustainability — 29 proposals, $11.32M requested, $500,000 awarded", "colour_Sustainability": "#9F694F"}};
const PALETTE_CATEGORICAL = ["#512888", "#CEA152", "#9F694F", "#AC9766", "#F4C55C", "#939193", "#835d09", "#E4D89E", "#FFF1C9", "#C8A2C8"];
const PALETTE_CATEGORICAL_20 = ["#512888", "#CEA152", "#9F694F", "#AC9766", "#F4C55C", "#939193", "#835d09", "#E4D89E", "#FFF1C9", "#C8A2C8", "#925cd8", "#e6c68f", "#d0a089", "#d4c39b", "#f9db9b", "#c6b2c6", "#f5c866", "#f1e9c3", "#fff6dd", "#e2c2e2"];
const PALETTE_SEQUENTIAL_ORDINAL = ["#9F694F", "#AC9766", "#CEA152", "#F4C55C", "#E4D89E", "#FFF1C9"];
const PALETTE_SEQUENTIAL_ORDINAL_10 = ["#9f694f", "#a6805b", "#ac9766", "#bd9c5c", "#cea152", "#e1b357", "#f4c55c", "#eccf7d", "#e4d89e", "#fff1c9"];
const PALETTE_SEQUENTIAL_NUMERIC_1O = ["#D3C0ED", "#C8A2C8", "#BB95C1", "#A37CB4", "#956EAD", "#8861A6", "#774F9D", "#6C4497", "#5D358F", "#512888"];
const PALETTE_SEQUENTIAL_NUMERIC_2O = ["#C8A2C8", "#C29CC5", "#BB95C1", "#B690BE", "#AD87B9", "#A983B7", "#A37CB4", "#9C75B0", "#956EAD", "#8F68AA", "#8861A6", "#7E57A1", "#774F9D", "#724A9A", "#6C4497", "#643C93", "#5D358F", "#562E8B", "#532B89", "#512888"];
const PALETTE_PRIMARY = {"purple": "#512888", "lilac": "#C8A2C8", "white": "#FFFFFF", "black": "#000000", "light_gray": "#D1D1D1", "mid_gray": "#A7A7A7", "sand": "#E7DED0"};
const PALETTE_ACCENT = {"cream_1": "#E7DED0", "cream_2": "#B9AB97", "gray_soft": "#939193", "dark_gold": "#835d09"};
const RFP_COLORS = {"AI": "#512888", "One K-State": "#CEA152", "Sustainability": "#9F694F"};
const RFP_TEXT_COLORS = {"AI": "#512888", "One K-State": "#835d09", "Sustainability": "#9F694F"};
const DEFAULT_TEXT_COLOR = "#000000";
const MODES = [
// {key:"pooled", label:"Pooled (all three)"}, // hidden for now
{key:"sidebyside", label:"Side by side"},
{key:"AI", label:"AI"},
{key:"One K-State", label:"One K-State"},
{key:"Sustainability", label:"Sustainability"},
];
const DEFAULT_MODE = "sidebyside";
const SINGLE_VIEWS = ["AI", "One K-State", "Sustainability"];
// Type is one knob: TYPE_STEP px is added to every font-size (CSS --type-step),
// and the label geometry below is re-derived from it so bigger text stays legible.
const TYPE_STEP = 7;
const NODE_W = 20;
const NAME_FS = 12 + TYPE_STEP; // matches .node-name
const SUB_FS = 10 + TYPE_STEP; // matches .node-sub
const LINE_H = NAME_FS + 3; // baseline gap within one node's label
const HALF_LH = LINE_H / 2;
const NODE_PAD = NAME_FS + SUB_FS + 4; // min gap so adjacent label blocks clear
const WRAP_CHARS = 45; // layer-3 titles wrap past this many chars
const M = {top:24, right:300, bottom:12, left:34};
const BAND_GAP = NODE_PAD + 22;
const CHART_MIN_H = 900; // fixed chart-height floor (CSS #chart min-height);
// below it the page scrolls, the funnel never shrinks
const fmtMoney = d3.format("$,.0f");
function fmtAmount(v){ return v >= 1e6 ? "$" + (v/1e6).toFixed(2) + "M" : fmtMoney(v); }
const container = document.getElementById("chart");
const svg = d3.select("#chart").append("svg");
const tip = d3.select("#tip");
let tipWeblink = null, tipHideTimer = null;
tip.on("mouseenter", () => { if (tipHideTimer){ clearTimeout(tipHideTimer); tipHideTimer = null; } })
.on("mouseleave", () => tip.style("display", "none"))
.on("click", () => { if (tipWeblink) window.open(tipWeblink, "_blank", "noopener"); });
const summaryEl = document.getElementById("summary");
const scaleEl = document.getElementById("scale-note");
let currentMode = DEFAULT_MODE;
let VW = 960;
// dropdown
d3.select("#mode").selectAll("option").data(MODES).join("option")
.attr("value", d => d.key)
.property("selected", d => d.key === DEFAULT_MODE)
.text(d => d.label);
d3.select("#mode").on("change", function(){ currentMode = this.value; render(); });
// legend
const legend = d3.select("#legend");
SINGLE_VIEWS.forEach(vk => {
const item = legend.append("span").attr("class", "legend-item");
item.append("span").attr("class", "swatch").style("background", DATA.meta["colour_" + vk]);
item.append("span").text(vk);
});
function colX(col, left, right){
// Compress the four columns leftward: layer 0 stays at the left margin and the
// step shrinks so layer 3 lands ~1/3 of an inter-layer gap further from the
// right edge, enlarging the label area for the long finalist titles.
const gap = (right - left - NODE_W) / 3; // original even inter-layer gap
const step = gap * 8 / 9; // layer 3 pulls in by 3 * gap/9 = gap/3
return left + col * step;
}
function wrapText(str, maxChars){
const lines = [];
let cur = "";
for (const word of String(str).split(/\s+/)){
const cand = cur ? cur + " " + word : word;
if (cand.length <= maxChars){ cur = cand; continue; }
if (cur) lines.push(cur);
if (word.length <= maxChars){ cur = word; }
else { // hard-break an over-long word
let rest = word;
while (rest.length > maxChars){ lines.push(rest.slice(0, maxChars)); rest = rest.slice(maxChars); }
cur = rest;
}
}
if (cur) lines.push(cur);
return lines;
}
// Distribute a column's nodes evenly across the band: equal gaps top to bottom,
// symmetric about the middle (each node centred in an equal-height slot). Node
// heights (ribbon widths) are kept; only centres move, and the incoming links
// are re-pointed to the new centres.
function spreadColumn(graph, col, bandTop, bandBot){
const ns = graph.nodes.filter(n => n.col === col)
.sort((a, b) => (a.y0 + a.y1) - (b.y0 + b.y1));
if (ns.length < 1) return;
const pad = LINE_H; // headroom so edge labels don't clip
const top = bandTop + pad, bot = bandBot - pad;
ns.forEach((n, i) => {
// edge-to-edge with equal gaps = maximum spread; a lone node centres, and the
// symmetry keeps the middle node of any odd count on the centre line.
const c = ns.length === 1 ? (bandTop + bandBot) / 2
: top + i * (bot - top) / (ns.length - 1);
const h = n.y1 - n.y0;
n.y0 = c - h / 2; n.y1 = c + h / 2;
});
graph.links.forEach(l => {
if (l.target && l.target.col === col) l.y1 = (l.target.y0 + l.target.y1) / 2;
});
}
// Pull the layer-2 'semi / redirected' drop-off node up so its centre sits on the
// same horizontal plane as the layer-1 'Not selected' node — the two drop-offs
// read as a band across the top. Node height kept; the advanced>semi link is
// re-pointed to the new centre.
function alignSemiToNotAdvanced(graph){
const na = graph.nodes.find(n => n.role === "not_advanced");
const semi = graph.nodes.find(n => n.role === "semi_not_selected");
if (!na || !semi) return;
const c = (na.y0 + na.y1) / 2, h = semi.y1 - semi.y0;
semi.y0 = c - h / 2; semi.y1 = c + h / 2;
graph.links.forEach(l => { if (l.target === semi) l.y1 = c; });
}
// One K-State's 'Not selected' node is small, so aligning its 'Redirected to other
// sources' semi node to that node's centre (alignSemiToNotAdvanced) pins it above
// the band and the label clips the title. Instead centre the semi node in the band
// — as far down as still keeps the 'Finalists' node below it inside the band — and
// shift Finalists (and its links) down to clear it.
function centreSemiInBand(graph, y0, y1){
const semi = graph.nodes.find(n => n.role === "semi_not_selected");
if (!semi) return;
const fin = graph.nodes.find(n => n.role === "finalists");
const hSemi = semi.y1 - semi.y0;
const hFin = fin ? fin.y1 - fin.y0 : 0;
const gap = NODE_PAD;
// deepest semi centre that still leaves room for Finalists below it in the band
const maxC = (fin ? y1 - hFin - gap : y1) - hSemi / 2;
let c = Math.min((y0 + y1) / 2, maxC); // band centre, capped so Finalists fits
c = Math.max(c, y0 + hSemi / 2); // never poke above the band top
semi.y0 = c - hSemi / 2; semi.y1 = c + hSemi / 2;
graph.links.forEach(l => { if (l.target === semi) l.y1 = c; });
if (fin && semi.y1 + gap > fin.y0){ // push Finalists + its links down to clear semi
const d = semi.y1 + gap - fin.y0;
fin.y0 += d; fin.y1 += d;
graph.links.forEach(l => {
if (l.target === fin) l.y1 += d;
if (l.source === fin) l.y0 += d;
});
}
}
function makeFunnel(viewKey, y0, y1, left, right){
const src = DATA.views[viewKey];
const nodes = src.nodes.map(n => Object.assign({}, n));
const links = src.links.map(l => Object.assign({}, l));
const sankey = d3.sankey()
.nodeId(d => d.node_id)
.nodeWidth(NODE_W)
.nodePadding(NODE_PAD)
.nodeAlign(d3.sankeyLeft)
.nodeSort((a, b) => a.order - b.order)
.extent([[left, y0], [right, y1]]);
const graph = sankey({nodes, links});
graph.nodes.forEach(n => { n.x0 = colX(n.col, left, right); n.x1 = n.x0 + NODE_W; });
spreadColumn(graph, 3, y0, y1); // keep wrapped layer-3 labels from overlapping
if (viewKey === "One K-State") centreSemiInBand(graph, y0, y1); // small 'Not selected' -> clips title; centre it
else alignSemiToNotAdvanced(graph); // layer-2 semi centre aligns with layer-1 'Not selected'
return {viewKey, sankey, graph, y0, y1, left, right, g:null};
}
function render(){
// The chart element carries the height floor in CSS (#chart min-height:CHART_MIN_H),
// so box.height is already >= the floor and H === the svg element's pixel height ===
// viewBox H -> the funnel never squishes; below the floor the page scrolls. The
// Math.max is belt-and-braces, matching the reference sankeys.
const rect = container.getBoundingClientRect();
const W = Math.max(720, Math.round(rect.width));
const H = Math.max(CHART_MIN_H, Math.round(rect.height));
VW = W;
svg.attr("width", W).attr("height", H).attr("viewBox", "0 0 " + W + " " + H);
svg.selectAll("*").remove();
const left = M.left, right = W - M.right, top = M.top, bot = H - M.bottom;
const funnels = [];
if (currentMode === "sidebyside"){
const n = SINGLE_VIEWS.length;
const bandH = (bot - top - BAND_GAP * (n - 1)) / n;
SINGLE_VIEWS.forEach((vk, i) => {
const y0 = top + i * (bandH + BAND_GAP);
funnels.push(makeFunnel(vk, y0, y0 + bandH, left, right));
});
summaryEl.textContent = "Side by side — " +
DATA.meta.summary_pooled.replace(/^All three — /, "");
} else {
funnels.push(makeFunnel(currentMode, top, bot, left, right));
summaryEl.textContent = DATA.meta["summary_" + currentMode] || DATA.meta.summary_pooled;
}
funnels.forEach(f => {
f.g = svg.append("g").attr("class", "funnel");
if (currentMode === "sidebyside"){
f.g.append("text").attr("class", "band-label")
.attr("x", left).attr("y", f.y0 - 10)
.text(DATA.meta["summary_" + f.viewKey]);
}
drawFunnel(f);
});
const allLinks = funnels.flatMap(f => f.graph.links);
const perPx = d3.min(allLinks, l => l.width > 0 ? l.value / l.width : Infinity);
scaleEl.textContent = (currentMode === "sidebyside")
? "Ribbon width = requested amount (each funnel self-scaled)"
: "Ribbon width = requested $ · 1 px ≈ " + fmtMoney(perPx);
}
function drawFunnel(f){
const g = f.g;
// Per-link gradient: source node colour -> target node colour (the reference
// sankey pattern). Re-laid on every draw so drag/resize keep x1/x2 current.
const gid = d => "grad-" + f.viewKey.replace(/[^A-Za-z0-9]/g, "") + "-" + d.index;
let defs = g.select("defs");
if (defs.empty()) defs = g.append("defs");
defs.selectAll("linearGradient").data(f.graph.links, d => d.index).join(
enter => {
const lg = enter.append("linearGradient").attr("gradientUnits", "userSpaceOnUse");
lg.append("stop").attr("class", "g0").attr("offset", "0%");
lg.append("stop").attr("class", "g1").attr("offset", "100%");
return lg;
},
update => update,
exit => exit.remove())
.attr("id", gid)
.attr("x1", d => d.source.x1).attr("x2", d => d.target.x0)
.attr("y1", 0).attr("y2", 0)
.call(sel => {
sel.select("stop.g0").attr("stop-color", d => d.source.color);
sel.select("stop.g1").attr("stop-color", d => d.target.color);
});
g.selectAll("path.link").data(f.graph.links, d => d.index).join(
enter => enter.append("path").attr("class", "link")
.attr("fill", "none").attr("stroke-opacity", 0.8),
update => update,
exit => exit.remove())
.attr("d", d3.sankeyLinkHorizontal())
.attr("stroke", d => "url(#" + gid(d) + ")")
.attr("stroke-width", d => Math.max(1.2, d.width))
.on("mousemove", (e, d) => showTip(e,
d.rfp + "<br>" + d.count + " proposal" + (d.count > 1 ? "s" : "") +
" · " + fmtAmount(d.value)))
.on("mouseout", hideTip);
const nodeSel = g.selectAll("g.node").data(f.graph.nodes, d => d.node_id);
const enter = nodeSel.enter().append("g").attr("class", "node");
enter.append("rect").attr("class", "node-rect");
enter.append("text").attr("class", "node-name");
enter.append("text").attr("class", "node-sub");
const node = enter.merge(nodeSel);
// tooltip on hover of the whole node (rect + label + subtext); finalists are
// clickable links to their project story.
node
.classed("clickable", d => d.role === "finalist" && !!d.weblink)
.on("mousemove", (e, d) => showTip(e, nodeTip(d), d.role === "finalist" ? d.weblink : null))
.on("mouseout", hideTip)
.on("click", (e, d) => {
if (d.role === "finalist" && d.weblink) window.open(d.weblink, "_blank", "noopener");
});
node.select("rect.node-rect")
.attr("x", d => d.x0).attr("y", d => d.y0)
.attr("width", d => d.x1 - d.x0)
.attr("height", d => Math.max(1, d.y1 - d.y0))
.attr("fill", d => d.color);
// layer-3 titles wrap at WRAP_CHARS into tspans; other node names stay one line.
node.select("text.node-name")
.attr("text-anchor", "start")
.each(function(d){
const lines = d.role === "finalist" ? wrapText(d.label, WRAP_CHARS) : [d.label];
d._nlines = lines.length;
const top = (d.y0 + d.y1) / 2 - lines.length * LINE_H / 2;
const t = d3.select(this);
t.selectAll("tspan").remove();
lines.forEach((ln, i) => {
t.append("tspan").attr("x", d.x1 + 8).attr("y", top + i * LINE_H).text(ln);
});
});
node.select("text.node-sub")
.attr("x", d => d.x1 + 8)
.attr("y", d => (d.y0 + d.y1) / 2 + (d._nlines || 1) * LINE_H / 2)
.attr("text-anchor", "start")
.text(d => d.sublabel);
nodeSel.exit().remove();
node.call(d3.drag()
.clickDistance(4)
.on("drag", function(event, d){
const h = d.y1 - d.y0;
const ny = Math.max(f.y0, Math.min(f.y1 - h, event.y));
d.y0 = ny; d.y1 = ny + h;
f.sankey.update(f.graph);
f.graph.nodes.forEach(n => { n.x0 = colX(n.col, f.left, f.right); n.x1 = n.x0 + NODE_W; });
drawFunnel(f);
}));
}
function nodeTip(d){
if (d.role === "finalist"){
let s = "<u>Title:</u> " + esc(d.title);
s += "<br><u>Award amount:</u> " + fmtMoney(d.dollars);
s += "<br><u>Open call:</u> " + esc(d.rfp);
if (d.dept) s += "<br><u>Department(s):</u> " + esc(d.dept);
if (d.contact) s += "<br><u>Contact:</u> " + esc(d.contact);
if (d.weblink) s += '<br><a href="' + d.weblink + '" target="_blank" rel="noopener">Click for story ↗</a>';
return s;
}
return esc(d.label) + "<br>" + d.count + " proposals · " + fmtMoney(d.dollars);
}
function esc(s){ return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); }
function showTip(event, html, weblink){
if (tipHideTimer){ clearTimeout(tipHideTimer); tipHideTimer = null; }
tipWeblink = weblink || null;
tip.html(html).classed("linked", !!tipWeblink).style("display", "block");
const pad = 14;
let x = event.clientX + pad, y = event.clientY + pad;
const bb = tip.node().getBoundingClientRect();
if (x + bb.width > window.innerWidth) x = event.clientX - bb.width - pad;
if (y + bb.height > window.innerHeight) y = event.clientY - bb.height - pad;
tip.style("left", x + "px").style("top", y + "px");
}
function hideTip(){
if (tipHideTimer) clearTimeout(tipHideTimer);
tipHideTimer = setTimeout(() => tip.style("display", "none"), 140);
}
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(render, 200);
});
render();
//__JS_END__
</script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>K-State Open-Call RFP Selection Funnel</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<style>
/* Mobile companion to the desktop K-State Open-Call RFP Selection Funnel.
Same DATA block, same palette, same d3-sankey layout engine. What changes
for a phone (the pattern follows gripex_sankey_mobile.html):
- The funnel keeps its left-to-right flow but is drawn tall, and the PAGE
scrolls (the desktop pins everything to 100vh with a 900px floor).
- Every label sits on top of its own bar. Four columns do not fit four
side-by-side label lanes on a phone, so each column's labels live in
the gap the ribbons arrive from, right-aligned to the bar, and the
"All proposals" label takes a row of its own above the flow. Lanes
never overlap and each column reserves its labels' height, so no two
labels can collide at any width.
- "Side by side" becomes "All": the three funnels stack, sharing the same
column positions, each self-scaled exactly as on the desktop.
- Hover tooltips become a tap-to-open detail card that opens directly
under the tapped bar or ribbon, with the finalist's story link as a
full-width button; the tapped node or ribbon is brought forward while
the rest dims.
- Embedded in an iframe (the CMS), the page sizes its frame to its own
content height, growing AND shrinking as views change: directly when
the host page is on the same origin, otherwise via a postMessage
{type:"kstate-funnel:height", height} the host page can listen for.
- The View dropdown becomes a segmented control that sticks to the top.
- Node dragging is dropped: on a touch screen it fights page scroll. */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--page-inset: 16px;
--purple: #512888;
--wash: #F3EEF9;
--rule: #D1D1D1;
--ink: #1a1a1a;
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { font-family: var(--font); background: #FFFFFF; color: var(--ink); }
header { padding: 18px var(--page-inset) 14px; }
h1 {
font-size: 22px; line-height: 1.2; font-weight: 650; color: var(--purple);
letter-spacing: -0.01em; text-wrap: balance;
}
.subtitle { margin-top: 6px; font-size: 15px; line-height: 1.4; color: #2a2a2a; }
.scale-note {
margin-top: 10px; font-size: 13.5px; line-height: 1.4; font-weight: 600;
color: var(--purple); background: var(--wash);
border-left: 3px solid var(--purple); padding: 7px 10px; border-radius: 2px;
}
/* ------------------------------------------------------- view switcher */
.views-bar {
position: sticky; top: 0; z-index: 5; background: #FFFFFF;
padding: 8px var(--page-inset); border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
}
/* Flex, not equal columns: "AI" is short, so it gives its share of the
width to "Sustainability" and every label stays on one line. */
.views {
display: flex; gap: 2px;
padding: 2px; border: 1px solid var(--purple); border-radius: 999px;
background: var(--wash);
}
.views button {
flex: 1 1 auto; min-width: 0;
min-height: 44px; padding: 4px 9px; border: 0; border-radius: 999px;
background: transparent; color: var(--purple);
font: 600 13.5px/1.15 var(--font); cursor: pointer; white-space: nowrap;
-webkit-tap-highlight-color: transparent; transition: background 0.12s, color 0.12s;
}
.views button[aria-pressed="true"] { background: var(--purple); color: #FFFFFF; }
.views button:focus-visible { outline: 2px solid var(--purple); outline-offset: 2px; }
@media (max-width: 359px) {
.views button { padding: 4px 6px; font-size: 12.5px; }
}
#summary {
padding: 14px var(--page-inset) 0; font-size: 14.5px; line-height: 1.4; font-weight: 600;
}
#summary:empty { display: none; }
/* -------------------------------------------------------------- funnels */
.funnel { padding-top: 16px; }
.funnel + .funnel { margin-top: 8px; border-top: 1px solid var(--rule); padding-top: 18px; }
.funnel-head { padding: 0 var(--page-inset); }
.funnel-head h2 {
display: flex; align-items: center; gap: 8px;
font-size: 17px; line-height: 1.25; font-weight: 700;
}
.funnel-head h2 i { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.funnel-head p { margin-top: 3px; font-size: 14px; line-height: 1.4; color: #333; }
.funnel svg {
display: block; margin-top: 10px; font-family: inherit; overflow: visible;
-webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none;
}
/* Wide ribbons are filled bands, not thick strokes: on a narrow screen the
curves are steep, and a wide stroke on a steep curve folds over itself
and bulges past the bar it lands on. A band's two edges cannot. Thin
ribbons stay strokes, which keep their thickness through the bend. */
.link { cursor: pointer; transition: fill-opacity 0.15s, stroke-opacity 0.15s; }
.link.band { stroke: none; fill-opacity: 0.78; }
.link.line { fill: none; stroke-opacity: 0.78; }
.link-hit { fill: none; stroke: #000; stroke-opacity: 0; pointer-events: stroke; cursor: pointer; }
.node { cursor: pointer; transition: opacity 0.15s; outline: none; }
.node-hit { fill: transparent; }
.node-ring { fill: none; stroke: none; }
.node:focus-visible .node-ring { stroke: var(--purple); stroke-width: 2; stroke-dasharray: 4 3; }
/* Selection: the chosen node or ribbon and everything it touches stay at
full strength; the rest steps back. */
svg.focus .link.band { fill-opacity: 0.14; }
svg.focus .link.line { stroke-opacity: 0.14; }
svg.focus .link.band.on { fill-opacity: 0.92; }
svg.focus .link.line.on { stroke-opacity: 0.92; }
svg.focus .node { opacity: 0.3; }
svg.focus .node.on { opacity: 1; }
.node.picked .node-rect { stroke: var(--ink); stroke-width: 1.5; }
.lbl {
pointer-events: none; paint-order: stroke; stroke: #FFFFFF; stroke-width: 3px;
stroke-linecap: round; stroke-linejoin: round;
}
.lbl-name { font-weight: 600; fill: var(--ink); }
.lbl-value { font-weight: 400; fill: #333333; }
footer {
margin-top: 18px; padding: 10px var(--page-inset) calc(18px + env(safe-area-inset-bottom));
border-top: 1px solid var(--rule);
font-size: 12.5px; line-height: 1.45; color: #595959;
display: flex; flex-direction: column; gap: 4px;
}
/* ---------------------------------------------------------- detail card */
/* Opens directly under the tapped bar or ribbon, with a caret pointing up
at it. It is placed in the page, not the viewport, so it also lands in
the right place inside an iframe sized to its content, where
position:fixed would pin it to the far bottom of the frame. */
main { position: relative; }
.pop {
position: absolute; z-index: 20; top: 0; left: var(--page-inset);
background: #FFFFFF; border: 1px solid #D9CFE6; border-radius: 12px;
box-shadow: 0 10px 30px rgba(20, 10, 32, 0.22);
opacity: 0; transform: translateY(-4px);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.pop.shown { opacity: 1; transform: none; }
.pop[hidden] { display: none; }
#pop-reveal { position: absolute; left: 0; width: 1px; visibility: hidden; pointer-events: none; }
#pop-reveal[hidden] { display: none; }
.pop-caret {
position: absolute; left: 20px; width: 14px; height: 14px;
background: #FFFFFF; border: 1px solid #D9CFE6; transform: rotate(45deg);
}
.pop-caret { top: -8px; border-right: 0; border-bottom: 0; }
.pop-head {
position: relative; display: flex; align-items: flex-start; gap: 8px;
padding: 2px 4px 10px 14px; border-bottom: 1px solid #ECE6F3;
}
.pop-titles { flex: 1; min-width: 0; padding-top: 10px; }
.pop-eyebrow {
display: flex; align-items: center; gap: 6px;
font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #444;
}
.pop-eyebrow i { width: 9px; height: 9px; border-radius: 2px; }
.pop-title {
margin-top: 4px; font-size: 16px; line-height: 1.3; font-weight: 700; color: var(--purple);
}
.pop-close {
flex-shrink: 0; width: 44px; height: 44px; border: 0; border-radius: 999px;
background: transparent; color: #333; display: grid; place-items: center; cursor: pointer;
}
.pop-close:focus-visible { outline: 2px solid var(--purple); outline-offset: -2px; }
.pop-body { padding: 0 14px 14px; }
.pop-body > .pop-row:last-child, .pop-body > .pop-field:last-child { border-bottom: 0; }
.pop-section {
margin-top: 14px; font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--purple);
}
.pop-row {
display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
padding: 9px 0; border-bottom: 1px solid #EEEEEE; font-size: 14.5px; line-height: 1.35;
}
.pop-row .k { color: #333; min-width: 0; }
.pop-row .k i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 7px; }
.pop-row .v { font-weight: 600; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
button.pop-row {
width: 100%; border: 0; border-bottom: 1px solid #EEEEEE; background: none;
font: inherit; font-size: 14.5px; text-align: left; color: inherit; cursor: pointer;
min-height: 44px; align-items: center; -webkit-tap-highlight-color: transparent;
}
button.pop-row .v { margin-left: auto; }
button.pop-row .chev { flex-shrink: 0; margin-left: -6px; font-size: 20px; line-height: 1; color: var(--purple); }
button.pop-row:focus-visible { outline: 2px solid var(--purple); outline-offset: -2px; }
.pop-field { padding: 9px 0; border-bottom: 1px solid #EEEEEE; font-size: 14.5px; line-height: 1.4; }
.pop-field .k { font-size: 12.5px; color: #595959; }
.pop-field .v { margin-top: 2px; color: var(--ink); }
.pop-cta {
display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 46px; margin-top: 16px; padding: 10px 14px; border-radius: 10px;
background: var(--purple); color: #FFFFFF; font-size: 15px; font-weight: 600;
text-decoration: none; -webkit-tap-highlight-color: transparent;
}
.pop-cta:focus-visible { outline: 2px solid var(--purple); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.pop, .link, .node, .views button { transition: none; }
}
/* hide the CMS DirectEdit "Updated" stamp injected into this standalone file */
a[href*="omniupdate.com"] { display: none !important; }
</style>
</head>
<body>
<div id="page">
<header>
<h1>K-State Open-Call RFP Selection Funnel</h1>
<p class="subtitle">See the flow of proposals from initial applicants to selected finalists</p>
<p class="scale-note" id="legend-caption"></p>
</header>
<nav class="views-bar" aria-label="Chart view">
<div class="views" id="views" role="group" aria-label="Show"></div>
</nav>
<main>
<p id="summary"></p>
<div id="chart"></div>
<div id="pop-reveal" aria-hidden="true" hidden></div>
<section id="pop" class="pop" role="dialog" aria-modal="false" aria-labelledby="pop-title" hidden>
<div class="pop-caret" aria-hidden="true"></div>
<div class="pop-head">
<div class="pop-titles">
<div class="pop-eyebrow" id="pop-eyebrow"></div>
<h2 id="pop-title" class="pop-title"></h2>
</div>
<button id="pop-close" class="pop-close" type="button" aria-label="Close details">
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 5l10 10M15 5L5 15"/></svg>
</button>
</div>
<div id="pop-body" class="pop-body"></div>
</section>
</main>
<footer>
<span>Tap a bar or ribbon for details.</span>
<span id="scale-note"></span>
</footer>
</div>
<script>
//__JS_BEGIN__
// ------------------------------------------------------------------- data
// Copied verbatim from the desktop file (DATA plus the palette constants).
// Regenerate both files from the same pipeline output so they never drift.
const DATA = {"views": {"AI": {"nodes": [{"node_id": "AI::total", "col": 0, "order": 0, "role": "total", "label": "All proposals", "sublabel": "27 proposals · $3.67M", "count": 27, "dollars": 3671986, "rfp": "AI", "color": "#8C71B0", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::not_advanced", "col": 1, "order": 0, "role": "not_advanced", "label": "Not selected", "sublabel": "20 proposals · $2.68M", "count": 20, "dollars": 2684964, "rfp": "AI", "color": "#6F4D9C", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::advanced", "col": 1, "order": 1, "role": "advanced", "label": "", "sublabel": "", "count": 7, "dollars": 987022, "rfp": "AI", "color": "#6F4D9C", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::semi_not_selected", "col": 2, "order": 0, "role": "semi_not_selected", "label": "Semi-finalists, not selected", "sublabel": "1 proposal · $93,000", "count": 1, "dollars": 93000, "rfp": "AI", "color": "#512888", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::finalists", "col": 2, "order": 1, "role": "finalists", "label": "Finalists", "sublabel": "6 proposals · $894,022", "count": 6, "dollars": 894022, "rfp": "AI", "color": "#512888", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "AI::fin::Extending K-State Student Success Initiatives via AI", "col": 3, "order": 0, "role": "finalist", "label": "Extending Student Success Initiatives via AI", "sublabel": "$209,022", "count": 1, "dollars": 209022, "rfp": "AI", "color": "#3F1F6A", "title": "Extending K-State Student Success Initiatives via AI", "dept": "DASSA", "contact": "Bill Watts", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "col": 3, "order": 1, "role": "finalist", "label": "Agricultural AI, Data and Digital Twins", "sublabel": "$180,000", "count": 1, "dollars": 180000, "rfp": "AI", "color": "#3F1F6A", "title": "Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "dept": "Biological and Agricultural Engineering", "contact": "Ajay Sharda", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::Regional Expansion of the K-State AI Symposium", "col": 3, "order": 2, "role": "finalist", "label": "Expansion of the K-State AI Symposium", "sublabel": "$143,000", "count": 1, "dollars": 143000, "rfp": "AI", "color": "#3F1F6A", "title": "Regional Expansion of the K-State AI Symposium", "dept": "Modern Languages", "contact": "Raelynne Hale", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::AI and Next-Gen Management in Business Initiative", "col": 3, "order": 3, "role": "finalist", "label": "AI and Next-Gen Management in Business", "sublabel": "$137,000", "count": 1, "dollars": 137000, "rfp": "AI", "color": "#3F1F6A", "title": "AI and Next-Gen Management in Business Initiative", "dept": "Department of Management", "contact": "Hansin Bilgili", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::Embedding Modern Computational Methods in High-Enrollment STEM Classes", "col": 3, "order": 4, "role": "finalist", "label": "Computational Methods in STEM", "sublabel": "$125,000", "count": 1, "dollars": 125000, "rfp": "AI", "color": "#3F1F6A", "title": "Embedding Modern Computational Methods in High-Enrollment STEM Classes", "dept": "Mathematics, Physics", "contact": "Andrew Bennett", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "AI::fin::Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "col": 3, "order": 5, "role": "finalist", "label": "AI for high-precision agriculture", "sublabel": "$100,000", "count": 1, "dollars": 100000, "rfp": "AI", "color": "#3F1F6A", "title": "Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "dept": "Computer Science, Biological and Agricultural Engineering", "contact": "Eugene Vasserman", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}], "links": [{"source": "AI::total", "target": "AI::not_advanced", "value": 2684964, "count": 20, "rfp": "AI", "color": "#512888", "link_group": "total>not_advanced"}, {"source": "AI::total", "target": "AI::advanced", "value": 987022, "count": 7, "rfp": "AI", "color": "#512888", "link_group": "total>advanced"}, {"source": "AI::advanced", "target": "AI::semi_not_selected", "value": 93000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "advanced>semi"}, {"source": "AI::advanced", "target": "AI::finalists", "value": 894022, "count": 6, "rfp": "AI", "color": "#512888", "link_group": "advanced>finalists"}, {"source": "AI::finalists", "target": "AI::fin::Extending K-State Student Success Initiatives via AI", "value": 209022, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "value": 180000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::Regional Expansion of the K-State AI Symposium", "value": 143000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::AI and Next-Gen Management in Business Initiative", "value": 137000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::Embedding Modern Computational Methods in High-Enrollment STEM Classes", "value": 125000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "AI::finalists", "target": "AI::fin::Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "value": 100000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}]}, "One K-State": {"nodes": [{"node_id": "One K-State::total", "col": 0, "order": 0, "role": "total", "label": "All proposals", "sublabel": "19 proposals · $2.57M", "count": 19, "dollars": 2572032, "rfp": "One K-State", "color": "#DFC18D", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::not_advanced", "col": 1, "order": 0, "role": "not_advanced", "label": "Not selected", "sublabel": "9 proposals · $883,078", "count": 9, "dollars": 883078, "rfp": "One K-State", "color": "#D6B16F", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::advanced", "col": 1, "order": 1, "role": "advanced", "label": "", "sublabel": "", "count": 10, "dollars": 1688954, "rfp": "One K-State", "color": "#D6B16F", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::semi_not_selected", "col": 2, "order": 0, "role": "semi_not_selected", "label": "Redirected to other sources", "sublabel": "6 proposals · $1.37M", "count": 6, "dollars": 1368454, "rfp": "One K-State", "color": "#CEA152", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::finalists", "col": 2, "order": 1, "role": "finalists", "label": "Finalists", "sublabel": "4 proposals · $320,500", "count": 4, "dollars": 320500, "rfp": "One K-State", "color": "#CEA152", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "One K-State::fin::Fermentation Innovation Hub at K-State Olathe", "col": 3, "order": 0, "role": "finalist", "label": "Fermentation Innovation at K-State Olathe", "sublabel": "$175,000", "count": 1, "dollars": 175000, "rfp": "One K-State", "color": "#A17E40", "title": "Fermentation Innovation Hub at K-State Olathe", "dept": "College of Applied and Professional Studies", "contact": "Darrin Smith", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "One K-State::fin::Faculty-Student Connections for Retention and Well-being", "col": 3, "order": 1, "role": "finalist", "label": "Faculty-Student Connections for Retention and Well-being", "sublabel": "$88,000", "count": 1, "dollars": 88000, "rfp": "One K-State", "color": "#A17E40", "title": "Faculty-Student Connections for Retention and Well-being", "dept": "Dean's Office in the College of Arts & Sciences", "contact": "Kimathi Choma", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "One K-State::fin::Veteran Affairs Work Study Enhancement Proposal", "col": 3, "order": 2, "role": "finalist", "label": "Veteran Affairs Work Study Enhancement", "sublabel": "$40,000", "count": 1, "dollars": 40000, "rfp": "One K-State", "color": "#A17E40", "title": "Veteran Affairs Work Study Enhancement Proposal", "dept": "Division of Academic Success and Student Affairs (DASSA)", "contact": "(Pending)", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "One K-State::fin::The Wildcat Well-being Challenge", "col": 3, "order": 3, "role": "finalist", "label": "The Wildcat Well-being Challenge", "sublabel": "$17,500", "count": 1, "dollars": 17500, "rfp": "One K-State", "color": "#A17E40", "title": "The Wildcat Well-being Challenge", "dept": "Student Well-being (DASSA)", "contact": "Austin Drake", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}], "links": [{"source": "One K-State::total", "target": "One K-State::not_advanced", "value": 883078, "count": 9, "rfp": "One K-State", "color": "#CEA152", "link_group": "total>not_advanced"}, {"source": "One K-State::total", "target": "One K-State::advanced", "value": 1688954, "count": 10, "rfp": "One K-State", "color": "#CEA152", "link_group": "total>advanced"}, {"source": "One K-State::advanced", "target": "One K-State::semi_not_selected", "value": 1368454, "count": 6, "rfp": "One K-State", "color": "#CEA152", "link_group": "advanced>semi"}, {"source": "One K-State::advanced", "target": "One K-State::finalists", "value": 320500, "count": 4, "rfp": "One K-State", "color": "#CEA152", "link_group": "advanced>finalists"}, {"source": "One K-State::finalists", "target": "One K-State::fin::Fermentation Innovation Hub at K-State Olathe", "value": 175000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "One K-State::finalists", "target": "One K-State::fin::Faculty-Student Connections for Retention and Well-being", "value": 88000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "One K-State::finalists", "target": "One K-State::fin::Veteran Affairs Work Study Enhancement Proposal", "value": 40000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "One K-State::finalists", "target": "One K-State::fin::The Wildcat Well-being Challenge", "value": 17500, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}]}, "Sustainability": {"nodes": [{"node_id": "Sustainability::total", "col": 0, "order": 0, "role": "total", "label": "All proposals", "sublabel": "29 proposals · $11.32M", "count": 29, "dollars": 11318054, "rfp": "Sustainability", "color": "#C09C8B", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::not_advanced", "col": 1, "order": 0, "role": "not_advanced", "label": "Not selected", "sublabel": "24 proposals · $9.09M", "count": 24, "dollars": 9088054, "rfp": "Sustainability", "color": "#AF826D", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::advanced", "col": 1, "order": 1, "role": "advanced", "label": "", "sublabel": "", "count": 5, "dollars": 2230000, "rfp": "Sustainability", "color": "#AF826D", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::semi_not_selected", "col": 2, "order": 0, "role": "semi_not_selected", "label": "Redirected to OVPR", "sublabel": "4 proposals · $1.73M", "count": 4, "dollars": 1730000, "rfp": "Sustainability", "color": "#9F694F", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::finalists", "col": 2, "order": 1, "role": "finalists", "label": "Finalists", "sublabel": "1 proposal · $500,000", "count": 1, "dollars": 500000, "rfp": "Sustainability", "color": "#9F694F", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "Sustainability::fin::Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "col": 3, "order": 0, "role": "finalist", "label": "Growing Sustainable Food Systems: Research, Learning, and Campus with Community Engagement", "sublabel": "$500,000", "count": 1, "dollars": 500000, "rfp": "Sustainability", "color": "#7C523E", "title": "Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "dept": "Horticulture and Natural Resources, Agricultural Economics, Agronomy, Sociology, Anthropology and Social Work, Social Transformation Studies, School of Health Sciences, College of Applied and Professional Studies at K-State Olathe, Animal Science, Housing and Dining Services, Ag Programs Office", "contact": "Bizhen Hu", "weblink": "https://www.k-state.edu/news/articles/2026/07/one-kstate-fund-sustainable-campus-food-system.html"}], "links": [{"source": "Sustainability::total", "target": "Sustainability::not_advanced", "value": 9088054, "count": 24, "rfp": "Sustainability", "color": "#9F694F", "link_group": "total>not_advanced"}, {"source": "Sustainability::total", "target": "Sustainability::advanced", "value": 2230000, "count": 5, "rfp": "Sustainability", "color": "#9F694F", "link_group": "total>advanced"}, {"source": "Sustainability::advanced", "target": "Sustainability::semi_not_selected", "value": 1730000, "count": 4, "rfp": "Sustainability", "color": "#9F694F", "link_group": "advanced>semi"}, {"source": "Sustainability::advanced", "target": "Sustainability::finalists", "value": 500000, "count": 1, "rfp": "Sustainability", "color": "#9F694F", "link_group": "advanced>finalists"}, {"source": "Sustainability::finalists", "target": "Sustainability::fin::Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "value": 500000, "count": 1, "rfp": "Sustainability", "color": "#9F694F", "link_group": "finalists>individual"}]}, "pooled": {"nodes": [{"node_id": "pooled::total", "col": 0, "order": 0, "role": "total", "label": "All proposals", "sublabel": "75 proposals · $17.56M", "count": 75, "dollars": 17562072, "rfp": "", "color": "#C5C5C5", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::not_advanced", "col": 1, "order": 0, "role": "not_advanced", "label": "Not selected", "sublabel": "53 proposals · $12.66M", "count": 53, "dollars": 12656096, "rfp": "", "color": "#B6B6B6", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::advanced", "col": 1, "order": 1, "role": "advanced", "label": "", "sublabel": "", "count": 22, "dollars": 4905976, "rfp": "", "color": "#B6B6B6", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::semi_not_selected", "col": 2, "order": 0, "role": "semi_not_selected", "label": "Semi-finalists, not selected", "sublabel": "11 proposals · $3.19M", "count": 11, "dollars": 3191454, "rfp": "", "color": "#A7A7A7", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::finalists", "col": 2, "order": 1, "role": "finalists", "label": "Finalists", "sublabel": "11 proposals · $1.71M", "count": 11, "dollars": 1714522, "rfp": "", "color": "#A7A7A7", "title": "", "dept": "", "contact": "", "weblink": ""}, {"node_id": "pooled::fin::Extending K-State Student Success Initiatives via AI", "col": 3, "order": 0, "role": "finalist", "label": "Extending Student Success Initiatives via AI", "sublabel": "$209,022", "count": 1, "dollars": 209022, "rfp": "AI", "color": "#3F1F6A", "title": "Extending K-State Student Success Initiatives via AI", "dept": "DASSA", "contact": "Bill Watts", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "col": 3, "order": 1, "role": "finalist", "label": "Agricultural AI, Data and Digital Twins", "sublabel": "$180,000", "count": 1, "dollars": 180000, "rfp": "AI", "color": "#3F1F6A", "title": "Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "dept": "Biological and Agricultural Engineering", "contact": "Ajay Sharda", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Regional Expansion of the K-State AI Symposium", "col": 3, "order": 2, "role": "finalist", "label": "Expansion of the K-State AI Symposium", "sublabel": "$143,000", "count": 1, "dollars": 143000, "rfp": "AI", "color": "#3F1F6A", "title": "Regional Expansion of the K-State AI Symposium", "dept": "Modern Languages", "contact": "Raelynne Hale", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::AI and Next-Gen Management in Business Initiative", "col": 3, "order": 3, "role": "finalist", "label": "AI and Next-Gen Management in Business", "sublabel": "$137,000", "count": 1, "dollars": 137000, "rfp": "AI", "color": "#3F1F6A", "title": "AI and Next-Gen Management in Business Initiative", "dept": "Department of Management", "contact": "Hansin Bilgili", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Embedding Modern Computational Methods in High-Enrollment STEM Classes", "col": 3, "order": 4, "role": "finalist", "label": "Computational Methods in STEM", "sublabel": "$125,000", "count": 1, "dollars": 125000, "rfp": "AI", "color": "#3F1F6A", "title": "Embedding Modern Computational Methods in High-Enrollment STEM Classes", "dept": "Mathematics, Physics", "contact": "Andrew Bennett", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "col": 3, "order": 5, "role": "finalist", "label": "AI for high-precision agriculture", "sublabel": "$100,000", "count": 1, "dollars": 100000, "rfp": "AI", "color": "#3F1F6A", "title": "Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "dept": "Computer Science, Biological and Agricultural Engineering", "contact": "Eugene Vasserman", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-drives-artificial-intelligence-research.html"}, {"node_id": "pooled::fin::Fermentation Innovation Hub at K-State Olathe", "col": 3, "order": 6, "role": "finalist", "label": "Fermentation Innovation at K-State Olathe", "sublabel": "$175,000", "count": 1, "dollars": 175000, "rfp": "One K-State", "color": "#A17E40", "title": "Fermentation Innovation Hub at K-State Olathe", "dept": "College of Applied and Professional Studies", "contact": "Darrin Smith", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "pooled::fin::Faculty-Student Connections for Retention and Well-being", "col": 3, "order": 7, "role": "finalist", "label": "Faculty-Student Connections for Retention and Well-being", "sublabel": "$88,000", "count": 1, "dollars": 88000, "rfp": "One K-State", "color": "#A17E40", "title": "Faculty-Student Connections for Retention and Well-being", "dept": "Dean's Office in the College of Arts & Sciences", "contact": "Kimathi Choma", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "pooled::fin::Veteran Affairs Work Study Enhancement Proposal", "col": 3, "order": 8, "role": "finalist", "label": "Veteran Affairs Work Study Enhancement", "sublabel": "$40,000", "count": 1, "dollars": 40000, "rfp": "One K-State", "color": "#A17E40", "title": "Veteran Affairs Work Study Enhancement Proposal", "dept": "Division of Academic Success and Student Affairs (DASSA)", "contact": "(Pending)", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "pooled::fin::The Wildcat Well-being Challenge", "col": 3, "order": 9, "role": "finalist", "label": "The Wildcat Well-being Challenge", "sublabel": "$17,500", "count": 1, "dollars": 17500, "rfp": "One K-State", "color": "#A17E40", "title": "The Wildcat Well-being Challenge", "dept": "Student Well-being (DASSA)", "contact": "Austin Drake", "weblink": "https://www.k-state.edu/news/articles/2026/05/one-kstate-fund-grants.html"}, {"node_id": "pooled::fin::Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "col": 3, "order": 10, "role": "finalist", "label": "Growing Sustainable Food Systems: Research, Learning, and Campus with Community Engagement", "sublabel": "$500,000", "count": 1, "dollars": 500000, "rfp": "Sustainability", "color": "#7C523E", "title": "Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "dept": "Horticulture and Natural Resources, Agricultural Economics, Agronomy, Sociology, Anthropology and Social Work, Social Transformation Studies, School of Health Sciences, College of Applied and Professional Studies at K-State Olathe, Animal Science, Housing and Dining Services, Ag Programs Office", "contact": "Bizhen Hu", "weblink": "https://www.k-state.edu/news/articles/2026/07/one-kstate-fund-sustainable-campus-food-system.html"}], "links": [{"source": "pooled::total", "target": "pooled::not_advanced", "value": 2684964, "count": 20, "rfp": "AI", "color": "#512888", "link_group": "total>not_advanced"}, {"source": "pooled::total", "target": "pooled::advanced", "value": 987022, "count": 7, "rfp": "AI", "color": "#512888", "link_group": "total>advanced"}, {"source": "pooled::advanced", "target": "pooled::semi_not_selected", "value": 93000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "advanced>semi"}, {"source": "pooled::advanced", "target": "pooled::finalists", "value": 894022, "count": 6, "rfp": "AI", "color": "#512888", "link_group": "advanced>finalists"}, {"source": "pooled::total", "target": "pooled::not_advanced", "value": 883078, "count": 9, "rfp": "One K-State", "color": "#CEA152", "link_group": "total>not_advanced"}, {"source": "pooled::total", "target": "pooled::advanced", "value": 1688954, "count": 10, "rfp": "One K-State", "color": "#CEA152", "link_group": "total>advanced"}, {"source": "pooled::advanced", "target": "pooled::semi_not_selected", "value": 1368454, "count": 6, "rfp": "One K-State", "color": "#CEA152", "link_group": "advanced>semi"}, {"source": "pooled::advanced", "target": "pooled::finalists", "value": 320500, "count": 4, "rfp": "One K-State", "color": "#CEA152", "link_group": "advanced>finalists"}, {"source": "pooled::total", "target": "pooled::not_advanced", "value": 9088054, "count": 24, "rfp": "Sustainability", "color": "#9F694F", "link_group": "total>not_advanced"}, {"source": "pooled::total", "target": "pooled::advanced", "value": 2230000, "count": 5, "rfp": "Sustainability", "color": "#9F694F", "link_group": "total>advanced"}, {"source": "pooled::advanced", "target": "pooled::semi_not_selected", "value": 1730000, "count": 4, "rfp": "Sustainability", "color": "#9F694F", "link_group": "advanced>semi"}, {"source": "pooled::advanced", "target": "pooled::finalists", "value": 500000, "count": 1, "rfp": "Sustainability", "color": "#9F694F", "link_group": "advanced>finalists"}, {"source": "pooled::finalists", "target": "pooled::fin::Extending K-State Student Success Initiatives via AI", "value": 209022, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Advancing Agricultural AI through Scalable Data Collection and Digital Twins for Phenotyping and Autonomy", "value": 180000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Regional Expansion of the K-State AI Symposium", "value": 143000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::AI and Next-Gen Management in Business Initiative", "value": 137000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Embedding Modern Computational Methods in High-Enrollment STEM Classes", "value": 125000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Trustworthy neurosymbolic AI for high-precision agricultural autonomy", "value": 100000, "count": 1, "rfp": "AI", "color": "#512888", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Fermentation Innovation Hub at K-State Olathe", "value": 175000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Faculty-Student Connections for Retention and Well-being", "value": 88000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Veteran Affairs Work Study Enhancement Proposal", "value": 40000, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::The Wildcat Well-being Challenge", "value": 17500, "count": 1, "rfp": "One K-State", "color": "#CEA152", "link_group": "finalists>individual"}, {"source": "pooled::finalists", "target": "pooled::fin::Growing Sustainable Food Systems: Linking K-State's Research, Learning, and Campus with Community Engagement", "value": 500000, "count": 1, "rfp": "Sustainability", "color": "#9F694F", "link_group": "finalists>individual"}]}}, "meta": {"diagram_type": "open-call RFP selection funnel (weighted by requested dollars)", "version": "v16", "generated": "2026-08-28", "source_csv": "project/data/raw/open_calls_selection_summary.csv", "width_semantics": "Ribbon width = requested dollars; node labels also carry proposal counts.", "colour_encoding": "Open_call (RFP)", "palette_source": "pres_export_palette.py (root: sankey_2026 pres_export_palette.R)", "modes": "pooled (default) | side by side | AI | One K-State | Sustainability", "default_mode": "pooled", "sidebyside_note": "composed at render time by stacking the AI, One K-State, Sustainability views", "pooled_link_note": "aggregate stage nodes merged; links split per RFP (three streams); aggregate-node fill neutral grey pending Phase 3", "dropoff_colour_note": "drop-off flows (total>not_advanced, advanced>semi) and their terminal 'not selected' nodes now use the full RFP colour, same as the survivors; pooled aggregate nodes stay neutral grey (they merge three RFPs)", "node_layer_ramp_note": "node fills step in lightness by layer: col 0-1 lighter, col 2 the base colour, col 3 darker (links are not ramped)", "node_align_note": "terminal nodes have no outgoing link — use nodeAlign(d3.sankeyLeft) or authored col", "layer_trap_note": "read the authored column off `col`, never d3's `.layer`", "legend_caption": "Ribbon width shows requested dollars. Node labels show proposal count and total requested.", "finalist_count": "11", "grand_total": "75 proposals, $17,562,072", "summary_pooled": "All three — 75 proposals, $17.56M requested, $1.71M awarded", "summary_AI": "AI — 27 proposals, $3.67M requested, $894,022 awarded", "colour_AI": "#512888", "summary_One K-State": "One K-State — 19 proposals, $2.57M requested, $320,500 awarded", "colour_One K-State": "#CEA152", "summary_Sustainability": "Sustainability — 29 proposals, $11.32M requested, $500,000 awarded", "colour_Sustainability": "#9F694F"}};
const PALETTE_CATEGORICAL = ["#512888", "#CEA152", "#9F694F", "#AC9766", "#F4C55C", "#939193", "#835d09", "#E4D89E", "#FFF1C9", "#C8A2C8"];
const PALETTE_CATEGORICAL_20 = ["#512888", "#CEA152", "#9F694F", "#AC9766", "#F4C55C", "#939193", "#835d09", "#E4D89E", "#FFF1C9", "#C8A2C8", "#925cd8", "#e6c68f", "#d0a089", "#d4c39b", "#f9db9b", "#c6b2c6", "#f5c866", "#f1e9c3", "#fff6dd", "#e2c2e2"];
const PALETTE_SEQUENTIAL_ORDINAL = ["#9F694F", "#AC9766", "#CEA152", "#F4C55C", "#E4D89E", "#FFF1C9"];
const PALETTE_SEQUENTIAL_ORDINAL_10 = ["#9f694f", "#a6805b", "#ac9766", "#bd9c5c", "#cea152", "#e1b357", "#f4c55c", "#eccf7d", "#e4d89e", "#fff1c9"];
const PALETTE_SEQUENTIAL_NUMERIC_1O = ["#D3C0ED", "#C8A2C8", "#BB95C1", "#A37CB4", "#956EAD", "#8861A6", "#774F9D", "#6C4497", "#5D358F", "#512888"];
const PALETTE_SEQUENTIAL_NUMERIC_2O = ["#C8A2C8", "#C29CC5", "#BB95C1", "#B690BE", "#AD87B9", "#A983B7", "#A37CB4", "#9C75B0", "#956EAD", "#8F68AA", "#8861A6", "#7E57A1", "#774F9D", "#724A9A", "#6C4497", "#643C93", "#5D358F", "#562E8B", "#532B89", "#512888"];
const PALETTE_PRIMARY = {"purple": "#512888", "lilac": "#C8A2C8", "white": "#FFFFFF", "black": "#000000", "light_gray": "#D1D1D1", "mid_gray": "#A7A7A7", "sand": "#E7DED0"};
const PALETTE_ACCENT = {"cream_1": "#E7DED0", "cream_2": "#B9AB97", "gray_soft": "#939193", "dark_gold": "#835d09"};
const RFP_COLORS = {"AI": "#512888", "One K-State": "#CEA152", "Sustainability": "#9F694F"};
const RFP_TEXT_COLORS = {"AI": "#512888", "One K-State": "#835d09", "Sustainability": "#9F694F"};
const DEFAULT_TEXT_COLOR = "#000000";
const MODES = [
// {key:"pooled", label:"Pooled (all three)"}, // hidden for now, as on desktop
{key:"sidebyside", label:"All"},
{key:"AI", label:"AI"},
{key:"One K-State", label:"One K-State"},
{key:"Sustainability", label:"Sustainability"},
];
const DEFAULT_MODE = "sidebyside";
const SINGLE_VIEWS = ["AI", "One K-State", "Sustainability"];
// ------------------------------------------------------------- constants
const NODE_W = 12; // bar width, px
const W_MIN = 2; // thinnest drawn ribbon, px (widened, never clamped)
const GUTTER = 8; // clear space between a label lane and the bar before it, px
const LABEL_GAP = 5; // label block bottom to bar top, px
const GAP_MIN = 14; // vertical gap between stacked nodes in a column, px
const TOP_PAD = 2; // svg top to the first label, px
const BOT_PAD = 10; // last bar to svg bottom, px
const BAND_MIN = 10; // ribbons this wide or wider draw as filled bands
const TAP_MIN = 44; // minimum tap target height, px
const BAR_HIT_W = 32; // tap width around a bar, px
const FINALIST_LINES = 8; // a finalist title wraps to at most this many lines (never reached today)
const FINALIST_MEASURE = 0.75; // finalist titles wrap within this share of their lane
const CURVE_AT = 0.3; // where along its run a ribbon bends (0.5 = d3 default)
const fmtMoney = d3.format("$,.0f");
const fmtInt = d3.format(",d");
const fmtPct = d3.format(".0%");
function fmtAmount(v){ return v >= 1e6 ? "$" + (v/1e6).toFixed(2) + "M" : fmtMoney(v); }
// Type sizes step down one notch on the narrowest phones.
function typeFor(W){
const name = W < 360 ? 12.5 : 13;
return { name, value: name - 1, lh: Math.round(name * 1.25) };
}
// Chart height follows width: the All-proposals bar (the whole flow) is
// drawn this tall, and every ribbon in that funnel is scaled from it.
function flowHeightFor(W){ return Math.round(Math.max(270, Math.min(430, W * 0.95))); }
// --------------------------------------------------------- text measuring
// Labels are wrapped by MEASURED width, on the device's own font, so the
// lanes fit whatever system face the phone renders with.
const FONT = getComputedStyle(document.body).fontFamily;
const measureCtx = document.createElement("canvas").getContext("2d");
function textW(str, font){ measureCtx.font = font; return measureCtx.measureText(str).width; }
// Break at spaces, and after a hyphen only between parts of 4+ characters
// ("Semi-|finalists", "Well-|being", never "K-|State" or "Next-|Gen").
function tokenize(text){
const tokens = [];
String(text).split(/[ \t\n]+/).filter(Boolean).forEach((word, wi) => {
const parts = [];
word.replace(/-/g, "-\u0000").split("\u0000").filter(Boolean).forEach(p => {
const letters = p.replace(/-$/, "").length;
if (parts.length && (letters < 4 || parts[parts.length - 1].replace(/-$/, "").length < 4)){
parts[parts.length - 1] += p;
} else parts.push(p);
});
parts.forEach((part, pi) => tokens.push({ t: part, sp: wi > 0 && pi === 0 }));
});
return tokens;
}
function greedy(tokens, maxW, font){
const lines = [];
let cur = "";
tokens.forEach(tk => {
const cand = cur ? cur + (tk.sp ? " " : "") + tk.t : tk.t;
if (!cur || textW(cand, font) <= maxW){ cur = cand; return; }
lines.push(cur);
cur = tk.t;
});
if (cur) lines.push(cur);
return lines.length ? lines : [""];
}
// Wrap to maxW, then narrow the measure as far as it goes without adding a
// line, so the lines come out balanced.
function wrapPx(text, maxW, font, maxLines){
const tokens = tokenize(text);
let lines = greedy(tokens, maxW, font);
if (lines.length > 1 && lines.length <= maxLines){
let lo = d3.max(tokens, tk => textW(tk.t, font)), hi = maxW;
for (let i = 0; i < 10 && hi - lo > 1; i++){
const mid = (lo + hi) / 2;
if (greedy(tokens, mid, font).length <= lines.length) hi = mid; else lo = mid;
}
lines = greedy(tokens, hi, font);
}
if (lines.length <= maxLines) return lines;
const kept = lines.slice(0, maxLines);
let last = kept[maxLines - 1];
while (last.length > 1 && textW(last + "…", font) > maxW) last = last.slice(0, -1).trimEnd();
kept[maxLines - 1] = last + "…";
return kept;
}
// Narrowest measure at which `text` wraps to at most `maxLines` lines.
function fitWidth(text, font, maxLines){
const tokens = tokenize(text);
let lo = d3.max(tokens, tk => textW(tk.t, font));
let hi = textW(greedy(tokens, Infinity, font)[0], font);
if (greedy(tokens, lo, font).length <= maxLines) return Math.ceil(lo);
for (let i = 0; i < 14 && hi - lo > 0.5; i++){
const mid = (lo + hi) / 2;
if (greedy(tokens, mid, font).length <= maxLines) hi = mid; else lo = mid;
}
return Math.ceil(hi);
}
// "20 proposals · $2.68M" stays on one line when the lane allows it, and
// otherwise breaks at the dot into "20 proposals" / "$2.68M".
function subParts(n){ return n.sublabel ? n.sublabel.split(" · ") : []; }
function valueLines(n, maxW, font){
if (!n.sublabel) return [];
if (textW(n.sublabel, font) <= maxW) return [n.sublabel];
return subParts(n).flatMap(p => wrapPx(p, maxW, font, 2));
}
function esc(s){
return String(s === undefined || s === null ? "" : s)
.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
// The pass-through node between 'All proposals' and the second cut has no
// label on the chart (as on desktop); the detail card still needs a name.
function stageName(n){ return n.label || (n.role === "advanced" ? "Advanced" : ""); }
// ------------------------------------------------------------------ state
let currentMode = DEFAULT_MODE;
let funnels = []; // laid-out funnels of the current render
let selected = null; // { view, type: "node" | "link", key }
let lastWidth = 0;
// ============================================================== geometry
// Column positions are shared by all three funnels, so the stacked view
// reads as one table and switching views never moves a column.
function geometry(W){
const inset = parseFloat(getComputedStyle(document.documentElement)
.getPropertyValue("--page-inset")) || 16;
const T = typeFor(W);
const NAME_FONT = `600 ${T.name}px ${FONT}`;
const VALUE_FONT = `400 ${T.value}px ${FONT}`;
const all = SINGLE_VIEWS.flatMap(vk => DATA.views[vk].nodes);
const col = c => all.filter(n => n.col === c);
const nameNeed = (ns, lines) => d3.max(ns, n => n.label ? fitWidth(n.label, NAME_FONT, lines) : 0) || 0;
const wordNeed = ns => d3.max(ns, n => n.label ? d3.max(tokenize(n.label), tk => textW(tk.t, NAME_FONT)) : 0) || 0;
const valNeed = ns => d3.max(ns, n => d3.max(subParts(n), p => textW(p, VALUE_FONT)) || 0) || 0;
// Lane c (c >= 1) runs from the previous bar's right edge + GUTTER to this
// column's bar's right edge; its labels are right-aligned to the bar.
const usable = W - 2 * inset - NODE_W - 3 * GUTTER;
const c1 = col(1), c2 = col(2), c3 = col(3);
const floor = NODE_W + 40; // keep a visible ribbon run
const want1 = Math.max(floor, nameNeed(c1, 1), valNeed(c1));
const want2 = Math.max(floor, nameNeed(c2, 2), valNeed(c2));
const min1 = Math.max(floor, wordNeed(c1), valNeed(c1));
const min2 = Math.max(floor, wordNeed(c2), valNeed(c2));
const want3 = Math.max(wordNeed(c3), valNeed(c3), usable * 0.42);
let z1 = want1, z2 = want2;
const over = z1 + z2 + want3 - usable;
if (over > 0){
const s1 = Math.max(0, z1 - min1), s2 = Math.max(0, z2 - min2), s = s1 + s2;
const take = Math.min(over, s);
if (s){ z1 -= take * s1 / s; z2 -= take * s2 / s; }
}
// On a wide screen (a phone turned sideways) lane 3 stops growing and the
// spare width spreads the first three columns out instead.
const cap3 = Math.max(want3, 220);
const spare = usable - z1 - z2 - cap3;
if (spare > 0){ z1 += spare / 2; z2 += spare / 2; }
z1 = Math.round(z1); z2 = Math.round(z2);
const xR = [];
xR[0] = inset + NODE_W;
xR[1] = xR[0] + GUTTER + z1;
xR[2] = xR[1] + GUTTER + z2;
xR[3] = W - inset;
const lanes = {
// 'All proposals' sits on a row of its own above the flow, so its lane
// can run across lanes 0 and 1.
0: { x0: inset, x1: xR[1], anchor: "start", tx: inset },
1: { x0: xR[0] + GUTTER, x1: xR[1], anchor: "end", tx: xR[1] },
2: { x0: xR[1] + GUTTER, x1: xR[2], anchor: "end", tx: xR[2] },
3: { x0: xR[2] + GUTTER, x1: xR[3], anchor: "end", tx: xR[3] },
};
Object.values(lanes).forEach(l => { l.w = l.x1 - l.x0; });
const minW3 = Math.max(wordNeed(c3), valNeed(c3));
return { W, inset, T, NAME_FONT, VALUE_FONT, xR, lanes, minW3, flowH: flowHeightFor(W) };
}
// ================================================================ layout
function layoutFunnel(viewKey, G){
const src = DATA.views[viewKey];
const nodes = src.nodes.map(n => Object.assign({}, n));
const links = src.links.map(l => Object.assign({}, l));
// d3-sankey builds the graph, node values and link breadths: with no node
// padding its scale is exactly flowH / (all proposals' dollars). The
// columns' x and y are then re-placed below so labels get their room.
const sankey = d3.sankey()
.nodeId(d => d.node_id)
.nodeAlign(n => n.col) // the authored column, never d3's .layer
.nodeWidth(NODE_W)
.nodePadding(0)
.nodeSort((a, b) => a.order - b.order)
.linkSort(null)
.extent([[G.inset, 0], [G.W - G.inset, G.flowH]]);
const g = sankey({ nodes, links });
const total = g.nodes.find(n => n.role === "total");
const ky = G.flowH / total.value;
g.links.forEach(l => { l.width = Math.max(W_MIN, l.value * ky); });
g.nodes.forEach(n => { n.x1 = G.xR[n.col]; n.x0 = n.x1 - NODE_W; });
// Label lines, wrapped to their lane.
g.nodes.forEach(n => {
const lane = G.lanes[n.col];
// Finalist titles wrap a little short of the lane's full width, leaving
// its left edge to the ribbons climbing to the bars above. A lone
// finalist has no neighbours' ribbons to make way for.
const crowded = n.col === 3 && nodes.filter(m => m.col === 3).length > 1;
const measure = crowded ? Math.max(G.minW3, lane.w * FINALIST_MEASURE) : lane.w;
const name = n.label
? wrapPx(n.label, measure, G.NAME_FONT, n.col === 3 ? FINALIST_LINES : 4) : [];
const vals = valueLines(n, lane.w, G.VALUE_FONT);
n._lines = name.map(t => ({ t, cls: "lbl lbl-name" }))
.concat(vals.map(t => ({ t, cls: "lbl lbl-value" })));
n._labelH = n._lines.length ? n._lines.length * G.T.lh + LABEL_GAP : 0;
});
const faceIn = n => d3.sum(n.targetLinks, l => l.width);
const faceOut = n => d3.sum(n.sourceLinks, l => l.width);
const faceH = n => Math.max(faceIn(n), faceOut(n));
const cols = d3.group(g.nodes, n => n.col);
for (const members of cols.values()) members.sort((a, b) => a.order - b.order);
const stack = (members, y, gap) => {
members.forEach((n, i) => {
if (i) y += gap;
n._labelTop = y;
n.y0 = y + n._labelH;
n.y1 = n.y0 + faceH(n);
y = n.y1;
});
return y;
};
const height = (members, gap) =>
d3.sum(members, n => n._labelH + faceH(n)) + gap * Math.max(0, members.length - 1);
// Column 0: its label on the top row, the bar straight beneath it.
const c0 = cols.get(0) || [], c1 = cols.get(1) || [], c2 = cols.get(2) || [], c3 = cols.get(3) || [];
let bottom = stack(c0, TOP_PAD, GAP_MIN);
// Column 1 starts below that top row (its lane shares the row's width).
// The 'All proposals' bar then drops to the level of the first column-1
// bar, so the first cut leaves it level, beneath the 'Not selected' label
// rather than through it.
const firstBar = c0.length ? c0[0].y0 : TOP_PAD;
bottom = Math.max(bottom, stack(c1, firstBar, GAP_MIN));
if (c0.length && c1.length){
const d = c1[0].y0 - c0[0].y0;
c0[0].y0 += d; c0[0].y1 += d;
bottom = Math.max(bottom, c0[0].y1);
}
// Column 2 is fed by the 'advanced' node: the last bar's bottom lines up
// with the feeder's, so the finalists ribbon runs level and the drop-off
// above it rises only by the height of one label.
const feeder2 = c1.find(n => n.role === "advanced") || c1[c1.length - 1];
const h2 = height(c2, GAP_MIN);
const y2 = Math.max(TOP_PAD, (feeder2 ? feeder2.y1 : TOP_PAD + h2) - h2);
bottom = Math.max(bottom, stack(c2, y2, GAP_MIN));
// Column 3 (the funded projects) centres on the 'Finalists' node, held
// inside the height the first three columns already need.
const feeder3 = c2.find(n => n.role === "finalists") || c2[c2.length - 1];
const gap3 = GAP_MIN - 2;
const h3 = height(c3, gap3);
let y3 = feeder3 ? (feeder3.y0 + feeder3.y1) / 2 - h3 / 2 : TOP_PAD;
if (y3 + h3 > bottom) y3 = bottom - h3;
y3 = Math.max(TOP_PAD, y3);
bottom = Math.max(bottom, stack(c3, y3, gap3));
// Ribbon ends stack in the order of the node at their other end, so
// ribbons leave and arrive without crossing at the bars.
g.nodes.forEach(n => {
n.sourceLinks.sort((a, b) => a.target.y0 - b.target.y0);
n.targetLinks.sort((a, b) => a.source.y0 - b.source.y0);
});
sankey.update(g);
return { viewKey, graph: g, ky, H: Math.ceil(bottom + BOT_PAD), faceIn, faceOut, total, svg: null };
}
// ================================================================= render
function render(){
const host = document.getElementById("chart");
d3.select(host).selectAll("*").remove();
const W = Math.max(300, Math.round(host.clientWidth));
lastWidth = W;
const G = geometry(W);
const keys = currentMode === "sidebyside" ? SINGLE_VIEWS : [currentMode];
funnels = keys.map(vk => {
const f = layoutFunnel(vk, G);
drawFunnel(host, f, G);
return f;
});
document.getElementById("summary").textContent = currentMode === "sidebyside"
? DATA.meta.summary_pooled : "";
document.getElementById("scale-note").textContent = currentMode === "sidebyside"
? "Ribbon width = requested amount (each funnel self-scaled)"
: "Ribbon width = requested $ · 1 px ≈ " + fmtMoney(1 / funnels[0].ky);
applySelection();
}
function drawFunnel(host, f, G){
const { W, T, lanes } = G;
const g = f.graph;
const vk = f.viewKey;
const slug = vk.replace(/[^A-Za-z0-9]/g, "");
const section = d3.select(host).append("section").attr("class", "funnel")
.attr("aria-label", vk + " open call");
const head = section.append("div").attr("class", "funnel-head");
const h2 = head.append("h2").style("color", RFP_TEXT_COLORS[vk] || DEFAULT_TEXT_COLOR);
h2.append("i").attr("aria-hidden", "true").style("background", RFP_COLORS[vk]);
h2.append("span").text(vk);
head.append("p").text((DATA.meta["summary_" + vk] || "").replace(/^.*? — /, ""));
const svg = section.append("svg")
.attr("width", W).attr("height", f.H)
.attr("viewBox", [0, 0, W, f.H])
.attr("role", "group")
.attr("aria-label", "Sankey diagram of the " + vk + " open call, from all proposals to funded finalists")
.on("click", () => clearSelection());
f.svg = svg;
// Per-link gradient: source node colour -> target node colour (as desktop).
const defs = svg.append("defs");
g.links.forEach((l, i) => {
l.gradientId = `grad-${slug}-${i}`;
const grad = defs.append("linearGradient")
.attr("id", l.gradientId).attr("gradientUnits", "userSpaceOnUse")
.attr("x1", l.source.x1).attr("x2", l.target.x0).attr("y1", 0).attr("y2", 0);
grad.append("stop").attr("offset", "0%").attr("stop-color", l.source.color);
grad.append("stop").attr("offset", "100%").attr("stop-color", l.target.color);
});
// ------------------------------------------------------------ ribbons
// The band between two horizontal-tangent curves, built from d3-sankey's
// own link.y0 / link.y1 / link.width. Unlike d3.sankeyLinkHorizontal the
// bend sits early (CURVE_AT of the run, not half way): every label is
// right-aligned above the bar a ribbon lands on, so a ribbon that has
// finished climbing before it reaches the label lane slides in beneath
// its own label instead of cutting through its neighbours'.
const bendX = l => l.source.x1 + (l.target.x0 - l.source.x1) * CURVE_AT;
function ribbonPath(l){
const x0 = l.source.x1, x1 = l.target.x0, xm = bendX(l), h = l.width / 2;
const a0 = l.y0 - h, b0 = l.y0 + h, a1 = l.y1 - h, b1 = l.y1 + h;
return `M${x0},${a0}C${xm},${a0} ${xm},${a1} ${x1},${a1}`
+ `L${x1},${b1}C${xm},${b1} ${xm},${b0} ${x0},${b0}Z`;
}
function centreLine(l){
const x0 = l.source.x1, x1 = l.target.x0, xm = bendX(l);
return `M${x0},${l.y0}C${xm},${l.y0} ${xm},${l.y1} ${x1},${l.y1}`;
}
const onLink = (event, d) => {
event.stopPropagation();
select({ view: vk, type: "link", key: linkKey(d) }, event);
};
// A wide invisible stroke under each thin ribbon so a 2 px ribbon is
// still tappable. Visible ribbons sit above, so where they overlap the
// one you can see is the one you get.
svg.append("g").attr("class", "link-hits")
.selectAll("path").data(g.links.filter(l => l.width < 16)).join("path")
.attr("class", "link-hit")
.attr("d", centreLine)
.attr("stroke-width", 16)
.on("click", onLink);
const isBand = l => l.width >= BAND_MIN;
svg.append("g").attr("class", "links")
.selectAll("path").data(g.links).join("path")
.attr("class", l => isBand(l) ? "link band" : "link line")
.attr("d", l => isBand(l) ? ribbonPath(l) : centreLine(l))
.attr("fill", l => isBand(l) ? `url(#${l.gradientId})` : null)
.attr("stroke", l => isBand(l) ? null : `url(#${l.gradientId})`)
.attr("stroke-width", l => isBand(l) ? null : l.width)
.on("click", onLink);
// -------------------------------------------------------------- nodes
const nodeG = svg.append("g").attr("class", "nodes")
.selectAll("g").data(g.nodes).join("g")
.attr("class", "node")
.attr("tabindex", 0)
.attr("role", "button")
.attr("aria-label", n => n.role === "finalist"
? `${n.title}. ${fmtMoney(n.dollars)}. Show details`
: `${stageName(n)}. ${n.count} proposal${n.count === 1 ? "" : "s"}, ${fmtMoney(n.dollars)}. Show details`)
.on("click", (event, n) => {
event.stopPropagation();
select({ view: vk, type: "node", key: n.node_id }, event);
})
.on("keydown", (event, n) => {
if (event.key === "Enter" || event.key === " "){
event.preventDefault();
select({ view: vk, type: "node", key: n.node_id }, event);
}
});
// Tap area: the whole label block across its lane, plus a column around
// the bar, at least TAP_MIN tall in all.
const blockTop = n => n._labelH ? n._labelTop : n.y0;
nodeG.filter(n => n._labelH > 0).append("rect").attr("class", "node-hit")
.attr("x", n => lanes[n.col].x0).attr("y", n => n._labelTop - 2)
.attr("width", n => lanes[n.col].w).attr("height", n => n._labelH + 2);
nodeG.append("rect").attr("class", "node-hit")
.attr("x", n => (n.x0 + n.x1) / 2 - BAR_HIT_W / 2)
.attr("y", n => Math.min(blockTop(n) - 2, (n.y0 + n.y1) / 2 - TAP_MIN / 2))
.attr("width", BAR_HIT_W)
.attr("height", n => Math.max(TAP_MIN, n.y1 - blockTop(n) + 4));
nodeG.append("rect").attr("class", "node-ring")
.attr("x", n => (n._labelH ? Math.min(lanes[n.col].x0, n.x0) : n.x0) - 3)
.attr("y", n => blockTop(n) - 4)
.attr("width", n => (n._labelH ? Math.max(lanes[n.col].x1, n.x1) - Math.min(lanes[n.col].x0, n.x0) : NODE_W) + 6)
.attr("height", n => n.y1 - blockTop(n) + 8).attr("rx", 4);
nodeG.append("rect")
.attr("class", "node-rect")
.attr("x", n => n.x0).attr("y", n => n.y0)
.attr("width", NODE_W)
.attr("height", n => Math.max(1, n.y1 - n.y0))
.attr("fill", n => n.color);
// Labels: name lines then value lines, stacked on top of the bar.
nodeG.each(function(n){
const cell = d3.select(this);
const lane = lanes[n.col];
n._lines.forEach((line, i) => {
const isName = line.cls.includes("lbl-name");
cell.append("text")
.attr("class", line.cls)
.attr("x", lane.tx)
.attr("y", n._labelTop + i * T.lh + T.lh * 0.78)
.attr("text-anchor", lane.anchor)
.attr("font-size", isName ? T.name : T.value)
.text(line.t);
});
});
}
// ============================================================== selection
function linkKey(l){ return l.source.node_id + "→" + l.target.node_id; }
function findSelected(){
if (!selected) return null;
const f = funnels.find(f => f.viewKey === selected.view);
if (!f) return null;
const item = selected.type === "node"
? f.graph.nodes.find(n => n.node_id === selected.key)
: f.graph.links.find(l => linkKey(l) === selected.key);
return item ? { f, item } : null;
}
function applySelection(){
funnels.forEach(f => {
f.svg.classed("focus", false);
f.svg.selectAll(".link, .node").classed("on", false).classed("picked", false);
});
const hit = findSelected();
if (!hit) return;
const { f, item } = hit;
const onNodes = new Set(), onLinks = new Set();
if (selected.type === "node"){
onNodes.add(item);
item.sourceLinks.concat(item.targetLinks).forEach(l => {
onLinks.add(l); onNodes.add(l.source); onNodes.add(l.target);
});
} else {
onLinks.add(item); onNodes.add(item.source); onNodes.add(item.target);
}
f.svg.classed("focus", true);
f.svg.selectAll(".link").classed("on", d => onLinks.has(d));
f.svg.selectAll(".node").classed("on", d => onNodes.has(d))
.classed("picked", d => selected.type === "node" && d === item);
}
let returnFocus = null;
function select(sel, event){
selected = sel;
applySelection();
const hit = findSelected();
if (!hit) return;
const viaKeyboard = !!event && event.type === "keydown";
if (event) returnFocus = viaKeyboard ? event.currentTarget : null;
const content = selected.type === "node" ? nodeDetail(hit.item, hit.f) : linkDetail(hit.item, hit.f);
openPop(hit.f, content, viaKeyboard, anchorFor(hit, event));
}
function clearSelection(){
if (!selected) return;
selected = null;
applySelection();
closePop();
}
// ============================================================ detail card
const pop = document.getElementById("pop");
const popReveal = document.getElementById("pop-reveal");
const pageEl = document.getElementById("page");
const mainEl = document.querySelector("main");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const POP_MAX_W = 440; // card width cap, px (it spans the page on a phone)
const POP_GAP = 12; // item edge to card edge, px (the caret sits in it)
const TALL_BAR = 150; // bars taller than this open the card where tapped
// What the card points at, in the funnel svg's coordinates: an x for the
// caret and the item's top and bottom edges. A node opens under its bar
// (labels sit above bars, so that is under the whole item); a tall bar opens
// where it was tapped instead of hundreds of pixels lower; a ribbon opens
// under the tap point.
function anchorFor(hit, event){
const { f, item } = hit;
const box = f.svg.node().getBoundingClientRect();
const tap = event && event.type !== "keydown" && event.clientX !== undefined
? { x: event.clientX - box.left, y: event.clientY - box.top } : null;
if (selected.type === "node"){
const n = item, x = (n.x0 + n.x1) / 2;
const top = n._labelH ? n._labelTop : n.y0;
if (n.y1 - n.y0 <= TALL_BAR) return { x, top, bottom: n.y1 };
const y = tap ? Math.max(n.y0 + 12, Math.min(n.y1, tap.y)) : n.y0 + 12;
return { x, top: Math.min(top, y), bottom: y };
}
if (tap) return { x: tap.x, top: tap.y, bottom: tap.y };
const l = item, y = (l.y0 + l.y1) / 2;
return { x: (l.source.x1 + l.target.x0) / 2, top: y - l.width / 2, bottom: y + l.width / 2 };
}
function openPop(f, { title, html }, focusClose, a){
document.getElementById("pop-eyebrow").innerHTML =
`<i style="background:${esc(RFP_COLORS[f.viewKey])}" aria-hidden="true"></i>`
+ esc(f.viewKey) + " open call";
document.getElementById("pop-title").textContent = title;
document.getElementById("pop-body").innerHTML = html;
pop.classList.remove("shown");
pop.hidden = false;
// Lay the card out in <main>'s coordinates: full width on a phone (capped
// on wider screens and centred on the item), caret over the item.
const inset = parseFloat(getComputedStyle(document.documentElement)
.getPropertyValue("--page-inset")) || 16;
const mainBox = mainEl.getBoundingClientRect();
const svgBox = f.svg.node().getBoundingClientRect();
const offX = svgBox.left - mainBox.left, offY = svgBox.top - mainBox.top;
const W = mainEl.clientWidth;
const w = Math.min(POP_MAX_W, W - 2 * inset);
const ax = offX + a.x;
const left = Math.max(inset, Math.min(W - inset - w, ax - w / 2));
pop.style.width = w + "px";
pop.style.left = left + "px";
pop.querySelector(".pop-caret").style.left = Math.max(14, Math.min(w - 28, ax - left - 7)) + "px";
// Always under the item. Near the end of the page the card simply runs
// past it: the page grows to hold it (and so does the host's frame).
pop.style.top = Math.round(offY + a.bottom + POP_GAP) + "px";
// What has to be on screen afterwards: the item (its last 120 px at
// most) down to the bottom of the card. An invisible box spans exactly
// that, and is what gets scrolled into view.
const itemH = Math.min(120, a.bottom - a.top);
popReveal.hidden = false;
const revealTop = offY + a.bottom - itemH - 8;
popReveal.style.top = Math.round(revealTop) + "px";
popReveal.style.height = Math.round(offY + a.bottom + POP_GAP + pop.offsetHeight + 16 - revealTop) + "px";
popReveal.style.scrollMarginTop = document.querySelector(".views-bar").offsetHeight + "px";
// Bring the card into view: this page scrolls, or the host page when
// embedded. A host on another origin resizes the frame only when our
// height message arrives, so wait for that resize before scrolling.
const sized = reportHeight();
const reveal = () => popReveal.scrollIntoView({ block: "nearest", behavior: reduceMotion.matches ? "auto" : "smooth" });
requestAnimationFrame(() => {
pop.classList.add("shown");
if (sized.changed && !sized.direct) afterFrameResize(reveal); else reveal();
});
if (focusClose) document.getElementById("pop-close").focus({ preventScroll: true });
}
function closePop(){
if (pop.hidden) return;
pop.hidden = true;
popReveal.hidden = true;
pop.classList.remove("shown");
reportHeight();
if (returnFocus && document.body.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
returnFocus = null;
}
document.getElementById("pop-close").addEventListener("click", clearSelection);
document.getElementById("pop-body").addEventListener("click", e => {
const b = e.target.closest(".pop-open");
if (!b || !selected) return;
e.stopPropagation(); // the row is replaced; don't let the page read it as an outside tap
select({ view: selected.view, type: "node", key: b.dataset.node }, null);
document.getElementById("pop-close").focus({ preventScroll: true }); // the row that had focus is gone
});
// A tap anywhere outside the card and the chart closes it (the chart's own
// background tap is handled on the svg).
document.addEventListener("click", e => {
if (pop.hidden) return;
const path = e.composedPath ? e.composedPath() : [];
if (path.includes(pop) || e.target.closest(".funnel svg")) return;
clearSelection();
});
document.addEventListener("keydown", e => { if (e.key === "Escape") clearSelection(); });
// ========================================================= frame height
// Inside an iframe the host page usually sizes the frame to the content.
// A host that measures document.scrollHeight can only ever grow the frame
// (scrollHeight never reports less than the frame's current height), which
// left the All view's height behind as white space after picking a single
// view. So the page reports its own height, from its content box: it sets
// the frame's height itself when the host is on the same origin, and posts
// it for a host on another origin to apply.
let lastReported = 0;
function reportHeight(){
if (window.parent === window) return { changed: false, direct: true };
let h = pageEl.getBoundingClientRect().height;
if (!pop.hidden){
const mainTop = mainEl.getBoundingClientRect().top - pageEl.getBoundingClientRect().top;
h = Math.max(h, mainTop + pop.offsetTop + pop.offsetHeight + 16);
}
h = Math.ceil(h);
if (h === lastReported) return { changed: false, direct: true };
lastReported = h;
let direct = false;
try {
const fe = window.frameElement;
if (fe){ fe.style.height = h + "px"; direct = true; }
} catch (err) { /* cross-origin host: the message below is all it gets */ }
window.parent.postMessage({ type: "kstate-funnel:height", height: h }, "*");
return { changed: true, direct };
}
function afterFrameResize(fn){
let done = false;
const go = () => { if (done) return; done = true; window.removeEventListener("resize", go); fn(); };
window.addEventListener("resize", go);
setTimeout(go, 400);
}
if (window.parent !== window){
if (window.ResizeObserver) new ResizeObserver(reportHeight).observe(pageEl);
window.addEventListener("load", reportHeight);
}
// A row with `open` is a button that swaps the card to that node.
function row(k, v, opts = {}){
const dot = opts.color ? `<i style="background:${esc(opts.color)}" aria-hidden="true"></i>` : "";
const inner = `<span class="k">${dot}${esc(k)}</span><span class="v">${esc(v)}</span>`;
return opts.open
? `<button type="button" class="pop-row pop-open" data-node="${esc(opts.open)}">${inner}<span class="chev" aria-hidden="true">›</span></button>`
: `<div class="pop-row">${inner}</div>`;
}
function field(k, v){
return `<div class="pop-field"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`;
}
const section = t => `<div class="pop-section">${esc(t)}</div>`;
const proposals = c => fmtInt(c) + " proposal" + (c === 1 ? "" : "s");
// Mirrors the desktop tooltips; on a phone a stage also lists where its
// proposals went, since tapping a thin ribbon is harder than hovering one.
function nodeDetail(n, f){
if (n.role === "finalist"){
let html = row("Award amount", fmtMoney(n.dollars));
html += row("Open call", n.rfp, { color: RFP_COLORS[n.rfp] });
if (n.dept) html += field("Department(s)", n.dept);
if (n.contact) html += field("Contact", n.contact);
if (n.weblink) html += `<a class="pop-cta" href="${esc(n.weblink)}" target="_blank" rel="noopener">Read the story <span aria-hidden="true">↗</span></a>`;
return { title: n.title || n.label, html };
}
let html = row("Proposals", fmtInt(n.count));
html += row("Requested", fmtMoney(n.dollars));
if (n.role !== "total"){
html += row("Share of all proposals", fmtPct(n.count / f.total.count));
html += row("Share of requested $", fmtPct(n.dollars / f.total.dollars));
}
if (n.sourceLinks.length){
const finals = n.role === "finalists";
html += section(finals ? "Funded projects" : "Went on to");
n.sourceLinks.forEach(l => {
html += finals
? row(l.target.label, fmtMoney(l.value), { color: l.target.color, open: l.target.node_id })
: row(stageName(l.target), proposals(l.count) + " · " + fmtAmount(l.value), { color: l.target.color });
});
}
return { title: stageName(n), html };
}
function linkDetail(l, f){
if (l.target.role === "finalist") return nodeDetail(l.target, f);
let html = row("Proposals", fmtInt(l.count));
html += row("Requested", fmtMoney(l.value));
html += row("Share of " + stageName(l.source).toLowerCase(), fmtPct(l.count / l.source.count));
return { title: `${stageName(l.source)} → ${stageName(l.target)}`, html };
}
// =========================================================== view switcher
const viewButtons = d3.select("#views").selectAll("button").data(MODES).join("button")
.attr("type", "button")
.text(m => m.label)
.on("click", (event, m) => {
if (m.key === currentMode) return;
currentMode = m.key;
applyView(true);
});
function applyView(fromSwitcher){
viewButtons.attr("aria-pressed", m => String(m.key === currentMode));
selected = null;
closePop();
render();
reportHeight();
// If the switcher is stuck to the top, bring the new chart's top up under it.
if (fromSwitcher){
const bar = document.querySelector(".views-bar");
const main = document.querySelector("main");
const top = main.getBoundingClientRect().top;
if (top < bar.offsetHeight){
window.scrollBy({ top: top - bar.offsetHeight, behavior: "auto" });
}
}
}
document.getElementById("legend-caption").textContent = DATA.meta.legend_caption;
applyView(false);
// Mobile browsers fire resize as the address bar shows and hides; only a
// change of WIDTH (rotation, split screen) needs a new layout.
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (Math.round(document.getElementById("chart").clientWidth) !== lastWidth){
clearSelection();
render();
}
}, 200);
});
//__JS_END__
</script>
</body>
</html>
Pop out this diagram and learn more about our One K-State Fund.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GRIPex: AI in the Disciplines</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
/* One knob for the page margin. Header, footer and empty state pad by it,
and the SVG legend reads it back so its left edge lines up with the
header text rather than with wherever the fund labels happen to end. */
/* Two knobs for the whole document, both set from 03_render_sankey.py.
--page-inset the page margin. Header, footer and the SVG legend all pad
by it, so their edges line up down both margins.
--type-step one step of type size, added to EVERY font-size above. The
JS reads it back as TYPE_STEP and re-derives the layout
constants that depend on text height, so bigger type gets
the extra room it needs instead of colliding. 0 restores
the original typography. */
:root { --page-inset: 28px; --type-step: 6px; }
html, body {
width: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: #FFFFFF;
color: #000000;
}
/* 100vh, not 100%: inside an iframe a percentage height needs every
ancestor to have a definite height, and when that chain breaks the
container collapses and the page grows a scrollbar. The viewport unit is
the iframe's own height regardless, so it does not depend on the embed. */
#container { display: flex; flex-direction: column; width: 100%; height: 100vh; }
header {
flex-shrink: 0; padding: 18px var(--page-inset) 12px;
border-bottom: 1px solid #D1D1D1;
position: relative; /* anchor for the top-right view slicer */
}
/* The view slicer: one pill button, top-right, that cycles All -> Awards
pending -> Awards secured -> All. A real <button> so Enter/Space work and
it is focusable; the label is rewritten on each click. */
.slicer {
position: absolute; top: 16px; right: var(--page-inset);
display: inline-flex; align-items: center; gap: 8px;
font-family: inherit; font-size: calc(12.5px + var(--type-step));
font-weight: 650; color: #512888;
background: #F3EEF9; border: 1px solid #512888; border-radius: 999px;
padding: 6px 14px; cursor: pointer; user-select: none;
transition: background 0.12s;
}
.slicer:hover { background: #E9DEF7; }
.slicer:focus-visible { outline: 2px solid #512888; outline-offset: 2px; }
.slicer .slicer-caret { opacity: 0.65; }
h1 { font-size: calc(20px + var(--type-step)); font-weight: 650; color: #512888; letter-spacing: -0.01em; }
.subtitle { margin-top: 4px; font-size: calc(14px + var(--type-step)); color: #2a2a2a; max-width: 96ch; }
.scale-note {
margin-top: 9px;
font-size: calc(12.5px + var(--type-step)); font-weight: 600; color: #512888;
background: #F3EEF9; border-left: 3px solid #512888;
padding: 6px 10px; border-radius: 2px; display: inline-block;
}
/* min-height MUST equal CHART_MIN_H in the script. The chart carries the
height floor here in CSS, so the measured box height is already >= the
floor and the svg is sized to exactly that box -- nothing overflows by a
sub-pixel and no phantom scrollbar appears. The page only scrolls when the
iframe is genuinely shorter than header + this floor + footer, which is
the one case a scrollbar is correct. (If these two numbers disagree the
svg can be a hair taller than its box and the scrollbar comes back.) */
#chart { flex: 1; width: 100%; position: relative; overflow: hidden; min-height: 480px; }
#chart svg { display: block; }
footer {
flex-shrink: 0; padding: 9px var(--page-inset); border-top: 1px solid #D1D1D1;
font-size: calc(11.5px + var(--type-step)); color: #666;
display: flex; gap: 22px; flex-wrap: wrap;
justify-content: space-between; align-items: baseline;
}
/* Written by render() and rewritten on resize -- the px-per-dollar figure
is only true for the size the chart was last laid out at. */
#scale-note { text-align: right; }
.link { fill: none; stroke-opacity: 0.44; transition: stroke-opacity 0.12s; }
.link:hover { stroke-opacity: 0.78; }
.node-shape { stroke: #FFFFFF; stroke-width: 1; shape-rendering: geometricPrecision; }
.node-hit { cursor: ns-resize; fill: transparent; stroke: none; }
/* Hover the wrapper, not the shape: the shape has pointer-events:none so it
can never be hovered itself, and the hit rect is its sibling inside .node. */
.node:hover .node-shape { stroke: #512888; stroke-width: 1.5; }
.node-label, .node-value, .col-head, .legend-text {
pointer-events: none;
paint-order: stroke;
stroke: #FFFFFF;
stroke-width: 3px;
stroke-linecap: round;
stroke-linejoin: round;
}
.node-label { font-size: calc(11.5px + var(--type-step)); font-weight: 600; fill: #1a1a1a; }
.node-value { font-size: calc(9.5px + var(--type-step)); fill: #666; }
.col-head {
font-size: calc(11px + var(--type-step)); font-weight: 700; fill: #512888;
letter-spacing: 0.09em; text-transform: uppercase;
}
.legend-text { font-size: calc(11.5px + var(--type-step)); fill: #333; }
.legend-caption {
font-size: calc(11.5px + var(--type-step)); fill: #444; font-style: italic;
paint-order: stroke; stroke: #FFFFFF; stroke-width: 3px;
stroke-linecap: round; stroke-linejoin: round;
}
/* position: fixed + display: none, NOT absolute + opacity: 0. An absolutely
positioned element with no left/top still sits in normal flow -- here
directly below a #container that is already the full viewport height, so
the body was taller than the iframe on load and drew a scrollbar until the
first hover pulled the tooltip out of flow. A fixed, display:none element
never adds to the scroll extent, so #chart's min-height is the only thing
that can scroll the page. Positioned in viewport coords (clientX/clientY),
the frame a fixed element lives in. Mirrors open_call_sankey's #tip. */
#tooltip {
position: fixed; display: none; max-width: 360px; padding: 10px 12px;
background: rgba(20, 10, 32, 0.94); color: #fff; border-radius: 5px;
pointer-events: none;
font-size: calc(12px + var(--type-step)); line-height: 1.5; z-index: 10;
box-shadow: 0 4px 14px rgba(0,0,0,0.28);
}
#tooltip .tt-title { font-weight: 700; margin-bottom: 5px; }
#tooltip .tt-row { display: flex; justify-content: space-between; gap: 14px; }
#tooltip .tt-key { color: #C8A2C8; }
#tooltip .tt-meta {
margin-top: 6px; padding-top: 6px;
border-top: 1px solid rgba(255,255,255,0.2);
color: #ddd; font-size: calc(11px + var(--type-step));
}
</style>
</head>
<body>
<div id="container">
<header>
<button id="slicer" class="slicer" type="button"
aria-label="Change display: cycles All, Awards pending, Awards secured">
<span id="slicer-label">All</span><span class="slicer-caret" aria-hidden="true">▸</span>
</button>
<h1>GRIPex: AI in the Disciplines</h1>
<div class="subtitle">See the outcomes of funding game-changing exploratory Artificial Intelligence (AI) research.</div>
<div class="scale-note">Ribbon width = Dollars in and dollars out. Count ribbons scales converted for comparison.</div>
</header>
<div id="chart"></div>
<footer>
<span>Hover a ribbon or node for detail</span>
<span id="scale-note"></span>
</footer>
</div>
<div id="tooltip"></div>
<script>
// ---------------------------------------------------------------- palette
const PALETTE_CATEGORICAL = ["#512888", "#CEA152", "#9F694F", "#AC9766", "#F4C55C", "#939193", "#835d09", "#E4D89E", "#FFF1C9", "#C8A2C8"];
const PALETTE_CATEGORICAL_20 = ["#512888", "#CEA152", "#9F694F", "#AC9766", "#F4C55C", "#939193", "#835d09", "#E4D89E", "#FFF1C9", "#C8A2C8", "#925cd8", "#e6c68f", "#d0a089", "#d4c39b", "#f9db9b", "#c6b2c6", "#f5c866", "#f1e9c3", "#fff6dd", "#e2c2e2"];
const PALETTE_SEQUENTIAL_ORDINAL = ["#9F694F", "#AC9766", "#CEA152", "#F4C55C", "#E4D89E", "#FFF1C9"];
const PALETTE_SEQUENTIAL_ORDINAL_10 = ["#9f694f", "#a6805b", "#ac9766", "#bd9c5c", "#cea152", "#e1b357", "#f4c55c", "#eccf7d", "#e4d89e", "#fff1c9"];
const PALETTE_SEQUENTIAL_NUMERIC_1O = ["#D3C0ED", "#C8A2C8", "#BB95C1", "#A37CB4", "#956EAD", "#8861A6", "#774F9D", "#6C4497", "#5D358F", "#512888"];
const PALETTE_SEQUENTIAL_NUMERIC_2O = ["#C8A2C8", "#C29CC5", "#BB95C1", "#B690BE", "#AD87B9", "#A983B7", "#A37CB4", "#9C75B0", "#956EAD", "#8F68AA", "#8861A6", "#7E57A1", "#774F9D", "#724A9A", "#6C4497", "#643C93", "#5D358F", "#562E8B", "#532B89", "#512888"];
const PALETTE_PRIMARY = {"purple": "#512888", "lilac": "#C8A2C8", "white": "#FFFFFF", "black": "#000000", "light_gray": "#D1D1D1", "mid_gray": "#A7A7A7", "sand": "#E7DED0"};
const PALETTE_ACCENT = {"cream_1": "#E7DED0", "cream_2": "#B9AB97", "gray_soft": "#939193", "dark_gold": "#835d09"};
const COL_COLORS = {"0": "#512888", "1": "#9F694F"};
const OUTCOME_COLORS = {"Proposal submissions": "#CEA152", "External awards (#)": "#939193", "External awards ($)": "#512888", "Publications": "#AC9766", "Presentations": "#F4C55C", "Pending awards ($)": "#C8A2C8"};
const OUTCOME_TEXT_COLORS = {"Proposal submissions": "#835d09", "External awards (#)": "#000000", "External awards ($)": "#512888", "Publications": "#835d09", "Presentations": "#000000", "Pending awards ($)": "#000000"};
const DEFAULT_TEXT_COLOR = "#000000";
// ------------------------------------------------------------------- data
// Three precomputed views, one per slicer option. Each carries its OWN nodes,
// links and D (dollars-per-count): the focused views hide one dollar outcome
// and use D=5000; the All view keeps everything at D=8000. Everything is built
// and validated in Phase 2, so switching views is a pointer swap + relayout,
// never a recompute here.
const VIEWS = [{"key": "all", "label": "All", "d": 8000, "nodes": [{"name": "One K-State Fund", "col": "0", "col_label": "Funding source", "label": "One K-State Fund", "legend_label": "One K-State Fund", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "500000", "display_total": "$500,000 across 5 projects", "subtitle": "$500,000", "subtitle2": "", "scaled_in": "0", "scaled_out": "500000", "node_value": "500000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Match Fund (OVPR)", "col": "0", "col_label": "Funding source", "label": "Match Fund (OVPR)", "legend_label": "Match Fund (OVPR)", "order": "1", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "499691", "display_total": "$499,691 across 5 projects", "subtitle": "$499,691", "subtitle2": "", "scaled_in": "0", "scaled_out": "499691", "node_value": "499691", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "AI for Pollinators: Developing a Global Hub", "col": "1", "col_label": "Project", "label": "AI for Pollinators: Developing a Global Hub", "legend_label": "AI for Pollinators: Developing a Global Hub", "order": "0", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "247475", "display_total": "$247,475", "subtitle": "$247,475 in", "subtitle2": "", "scaled_in": "247475", "scaled_out": "3349380", "node_value": "3349380", "full_title": "AI for Pollinators: Developing a Global Hub for Collaboration, Data Sharing, and Pollinator Research", "college": "Agriculture", "department": "Entomology", "pi": "Brian Spiesman"}, {"name": "AI-based Molecular Image Analysis", "col": "1", "col_label": "Project", "label": "AI-based Molecular Image Analysis", "legend_label": "AI-based Molecular Image Analysis", "order": "1", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "179249", "display_total": "$179,249", "subtitle": "$179,249 in", "subtitle2": "", "scaled_in": "179249", "scaled_out": "1120000", "node_value": "1120000", "full_title": "AI-based Molecular Image Analysis for Ultrafast Laser Experiments", "college": "Arts & Sciences", "department": "Physics", "pi": "Daniel Rolles"}, {"name": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "col": "1", "col_label": "Project", "label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "legend_label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "order": "2", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "248173", "display_total": "$248,173", "subtitle": "$248,173 in", "subtitle2": "", "scaled_in": "248173", "scaled_out": "1024000", "node_value": "1024000", "full_title": "Mapping Seed-to-Plant Life Cycle to Predict Yields — Integrated Artificial Intelligence and Data Analytics Approach", "college": "Engineering", "department": "Biological and Agricultural Engineering", "pi": "Ajay Sharda"}, {"name": "Collaborating with AI: Writing Studies-Informed Applications", "col": "1", "col_label": "Project", "label": "Collaborating with AI: Writing Studies-Informed Applications", "legend_label": "Collaborating with AI: Writing Studies-Informed Applications", "order": "3", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "100356", "display_total": "$100,356", "subtitle": "$100,356 in", "subtitle2": "", "scaled_in": "100356", "scaled_out": "536000", "node_value": "536000", "full_title": "Collaborating with AI: A Project to Develop Writing Studies-Informed Applications of Generative AI for Teaching in Higher Education", "college": "Arts & Sciences", "department": "English", "pi": "Cydney Alexis"}, {"name": "Training Agents to Detect Early Cognitive Decline", "col": "1", "col_label": "Project", "label": "Training Agents to Detect Early Cognitive Decline", "legend_label": "Training Agents to Detect Early Cognitive Decline", "order": "4", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "224438", "display_total": "$224,438", "subtitle": "$224,438 in", "subtitle2": "", "scaled_in": "224438", "scaled_out": "40000", "node_value": "224438", "full_title": "Training Artificial Intelligence Conversational Agents to Detect Early Cognitive Decline", "college": "Arts & Sciences", "department": "Psychological Sciences", "pi": "Heather Bailey"}, {"name": "External awards ($)", "col": "2", "col_label": "Outcome type", "label": "External awards ($)", "legend_label": "Funding", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "2769401", "display_total": "$2,769,401", "subtitle": "3 awards", "subtitle2": "$2,769,401 total", "scaled_in": "2769401", "scaled_out": "0", "node_value": "2769401", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Publications", "col": "2", "col_label": "Outcome type", "label": "Publications", "legend_label": "Publications", "order": "1", "color": "#AC9766", "text_color": "#835d09", "unit": "Publications", "true_total": "9", "display_total": "9 Publications", "subtitle": "9 publications", "subtitle2": "", "scaled_in": "72000", "scaled_out": "0", "node_value": "72000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Presentations", "col": "2", "col_label": "Outcome type", "label": "Presentations", "legend_label": "Presentations", "order": "2", "color": "#F4C55C", "text_color": "#000000", "unit": "Presentations", "true_total": "91", "display_total": "91 Presentations", "subtitle": "91 presentations", "subtitle2": "", "scaled_in": "728000", "scaled_out": "0", "node_value": "728000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Pending awards ($)", "col": "2", "col_label": "Outcome type", "label": "Pending awards ($)", "legend_label": "Pending awards ($)", "order": "3", "color": "#C8A2C8", "text_color": "#000000", "unit": "Dollars", "true_total": "2499979", "display_total": "$2,499,979", "subtitle": "13 submissions", "subtitle2": "$2,499,979 total", "scaled_in": "2499979", "scaled_out": "0", "node_value": "2499979", "full_title": "", "college": "", "department": "", "pi": ""}], "links": [{"source": "One K-State Fund", "target": "AI-based Molecular Image Analysis", "value": "61561", "trueValue": "61561", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI-based Molecular Image Analysis", "value": "117688", "trueValue": "117688", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "45453", "trueValue": "45453", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "54903", "trueValue": "54903", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Training Agents to Detect Early Cognitive Decline", "value": "117358", "trueValue": "117358", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Training Agents to Detect Early Cognitive Decline", "value": "107080", "trueValue": "107080", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "124628", "trueValue": "124628", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "123545", "trueValue": "123545", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "AI for Pollinators: Developing a Global Hub", "value": "151000", "trueValue": "151000", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI for Pollinators: Developing a Global Hub", "value": "96475", "trueValue": "96475", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "AI-based Molecular Image Analysis", "target": "External awards ($)", "value": "1000000", "trueValue": "1000000", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Publications", "value": "32000", "trueValue": "4", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Presentations", "value": "88000", "trueValue": "11", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Publications", "value": "8000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Presentations", "value": "528000", "trueValue": "66", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Publications", "value": "8000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Presentations", "value": "32000", "trueValue": "4", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Presentations", "value": "24000", "trueValue": "3", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Pending awards ($)", "value": "1000000", "trueValue": "1000000", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "External awards ($)", "value": "1769401", "trueValue": "1769401", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Publications", "value": "24000", "trueValue": "3", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Presentations", "value": "56000", "trueValue": "7", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Pending awards ($)", "value": "1499979", "trueValue": "1499979", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}]}, {"key": "pending", "label": "Awards pending", "d": 5000, "nodes": [{"name": "One K-State Fund", "col": "0", "col_label": "Funding source", "label": "One K-State Fund", "legend_label": "One K-State Fund", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "500000", "display_total": "$500,000 across 5 projects", "subtitle": "$500,000", "subtitle2": "", "scaled_in": "0", "scaled_out": "500000", "node_value": "500000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Match Fund (OVPR)", "col": "0", "col_label": "Funding source", "label": "Match Fund (OVPR)", "legend_label": "Match Fund (OVPR)", "order": "1", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "499691", "display_total": "$499,691 across 5 projects", "subtitle": "$499,691", "subtitle2": "", "scaled_in": "0", "scaled_out": "499691", "node_value": "499691", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "AI for Pollinators: Developing a Global Hub", "col": "1", "col_label": "Project", "label": "AI for Pollinators: Developing a Global Hub", "legend_label": "AI for Pollinators: Developing a Global Hub", "order": "0", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "247475", "display_total": "$247,475", "subtitle": "$247,475 in", "subtitle2": "", "scaled_in": "247475", "scaled_out": "1549979", "node_value": "1549979", "full_title": "AI for Pollinators: Developing a Global Hub for Collaboration, Data Sharing, and Pollinator Research", "college": "Agriculture", "department": "Entomology", "pi": "Brian Spiesman"}, {"name": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "col": "1", "col_label": "Project", "label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "legend_label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "order": "1", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "248173", "display_total": "$248,173", "subtitle": "$248,173 in", "subtitle2": "", "scaled_in": "248173", "scaled_out": "1015000", "node_value": "1015000", "full_title": "Mapping Seed-to-Plant Life Cycle to Predict Yields — Integrated Artificial Intelligence and Data Analytics Approach", "college": "Engineering", "department": "Biological and Agricultural Engineering", "pi": "Ajay Sharda"}, {"name": "Collaborating with AI: Writing Studies-Informed Applications", "col": "1", "col_label": "Project", "label": "Collaborating with AI: Writing Studies-Informed Applications", "legend_label": "Collaborating with AI: Writing Studies-Informed Applications", "order": "2", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "100356", "display_total": "$100,356", "subtitle": "$100,356 in", "subtitle2": "", "scaled_in": "100356", "scaled_out": "335000", "node_value": "335000", "full_title": "Collaborating with AI: A Project to Develop Writing Studies-Informed Applications of Generative AI for Teaching in Higher Education", "college": "Arts & Sciences", "department": "English", "pi": "Cydney Alexis"}, {"name": "Training Agents to Detect Early Cognitive Decline", "col": "1", "col_label": "Project", "label": "Training Agents to Detect Early Cognitive Decline", "legend_label": "Training Agents to Detect Early Cognitive Decline", "order": "3", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "224438", "display_total": "$224,438", "subtitle": "$224,438 in", "subtitle2": "", "scaled_in": "224438", "scaled_out": "25000", "node_value": "224438", "full_title": "Training Artificial Intelligence Conversational Agents to Detect Early Cognitive Decline", "college": "Arts & Sciences", "department": "Psychological Sciences", "pi": "Heather Bailey"}, {"name": "AI-based Molecular Image Analysis", "col": "1", "col_label": "Project", "label": "AI-based Molecular Image Analysis", "legend_label": "AI-based Molecular Image Analysis", "order": "4", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "179249", "display_total": "$179,249", "subtitle": "$179,249 in", "subtitle2": "", "scaled_in": "179249", "scaled_out": "75000", "node_value": "179249", "full_title": "AI-based Molecular Image Analysis for Ultrafast Laser Experiments", "college": "Arts & Sciences", "department": "Physics", "pi": "Daniel Rolles"}, {"name": "Publications", "col": "2", "col_label": "Outcome type", "label": "Publications", "legend_label": "Publications", "order": "0", "color": "#AC9766", "text_color": "#835d09", "unit": "Publications", "true_total": "9", "display_total": "9 Publications", "subtitle": "9 publications", "subtitle2": "", "scaled_in": "45000", "scaled_out": "0", "node_value": "45000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Presentations", "col": "2", "col_label": "Outcome type", "label": "Presentations", "legend_label": "Presentations", "order": "1", "color": "#F4C55C", "text_color": "#000000", "unit": "Presentations", "true_total": "91", "display_total": "91 Presentations", "subtitle": "91 presentations", "subtitle2": "", "scaled_in": "455000", "scaled_out": "0", "node_value": "455000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Pending awards ($)", "col": "2", "col_label": "Outcome type", "label": "Pending awards ($)", "legend_label": "Pending awards ($)", "order": "2", "color": "#C8A2C8", "text_color": "#000000", "unit": "Dollars", "true_total": "2499979", "display_total": "$2,499,979", "subtitle": "13 submissions", "subtitle2": "$2,499,979 total", "scaled_in": "2499979", "scaled_out": "0", "node_value": "2499979", "full_title": "", "college": "", "department": "", "pi": ""}], "links": [{"source": "One K-State Fund", "target": "AI-based Molecular Image Analysis", "value": "61561", "trueValue": "61561", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI-based Molecular Image Analysis", "value": "117688", "trueValue": "117688", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "45453", "trueValue": "45453", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "54903", "trueValue": "54903", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Training Agents to Detect Early Cognitive Decline", "value": "117358", "trueValue": "117358", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Training Agents to Detect Early Cognitive Decline", "value": "107080", "trueValue": "107080", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "124628", "trueValue": "124628", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "123545", "trueValue": "123545", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "AI for Pollinators: Developing a Global Hub", "value": "151000", "trueValue": "151000", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI for Pollinators: Developing a Global Hub", "value": "96475", "trueValue": "96475", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "AI-based Molecular Image Analysis", "target": "Publications", "value": "20000", "trueValue": "4", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Presentations", "value": "55000", "trueValue": "11", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Publications", "value": "5000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Presentations", "value": "330000", "trueValue": "66", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Publications", "value": "5000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Presentations", "value": "20000", "trueValue": "4", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Presentations", "value": "15000", "trueValue": "3", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Pending awards ($)", "value": "1000000", "trueValue": "1000000", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Publications", "value": "15000", "trueValue": "3", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Presentations", "value": "35000", "trueValue": "7", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Pending awards ($)", "value": "1499979", "trueValue": "1499979", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}]}, {"key": "secured", "label": "Awards secured", "d": 5000, "nodes": [{"name": "One K-State Fund", "col": "0", "col_label": "Funding source", "label": "One K-State Fund", "legend_label": "One K-State Fund", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "500000", "display_total": "$500,000 across 5 projects", "subtitle": "$500,000", "subtitle2": "", "scaled_in": "0", "scaled_out": "500000", "node_value": "500000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Match Fund (OVPR)", "col": "0", "col_label": "Funding source", "label": "Match Fund (OVPR)", "legend_label": "Match Fund (OVPR)", "order": "1", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "499691", "display_total": "$499,691 across 5 projects", "subtitle": "$499,691", "subtitle2": "", "scaled_in": "0", "scaled_out": "499691", "node_value": "499691", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "AI for Pollinators: Developing a Global Hub", "col": "1", "col_label": "Project", "label": "AI for Pollinators: Developing a Global Hub", "legend_label": "AI for Pollinators: Developing a Global Hub", "order": "0", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "247475", "display_total": "$247,475", "subtitle": "$247,475 in", "subtitle2": "", "scaled_in": "247475", "scaled_out": "1819401", "node_value": "1819401", "full_title": "AI for Pollinators: Developing a Global Hub for Collaboration, Data Sharing, and Pollinator Research", "college": "Agriculture", "department": "Entomology", "pi": "Brian Spiesman"}, {"name": "AI-based Molecular Image Analysis", "col": "1", "col_label": "Project", "label": "AI-based Molecular Image Analysis", "legend_label": "AI-based Molecular Image Analysis", "order": "1", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "179249", "display_total": "$179,249", "subtitle": "$179,249 in", "subtitle2": "", "scaled_in": "179249", "scaled_out": "1075000", "node_value": "1075000", "full_title": "AI-based Molecular Image Analysis for Ultrafast Laser Experiments", "college": "Arts & Sciences", "department": "Physics", "pi": "Daniel Rolles"}, {"name": "Collaborating with AI: Writing Studies-Informed Applications", "col": "1", "col_label": "Project", "label": "Collaborating with AI: Writing Studies-Informed Applications", "legend_label": "Collaborating with AI: Writing Studies-Informed Applications", "order": "2", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "100356", "display_total": "$100,356", "subtitle": "$100,356 in", "subtitle2": "", "scaled_in": "100356", "scaled_out": "335000", "node_value": "335000", "full_title": "Collaborating with AI: A Project to Develop Writing Studies-Informed Applications of Generative AI for Teaching in Higher Education", "college": "Arts & Sciences", "department": "English", "pi": "Cydney Alexis"}, {"name": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "col": "1", "col_label": "Project", "label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "legend_label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "order": "3", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "248173", "display_total": "$248,173", "subtitle": "$248,173 in", "subtitle2": "", "scaled_in": "248173", "scaled_out": "15000", "node_value": "248173", "full_title": "Mapping Seed-to-Plant Life Cycle to Predict Yields — Integrated Artificial Intelligence and Data Analytics Approach", "college": "Engineering", "department": "Biological and Agricultural Engineering", "pi": "Ajay Sharda"}, {"name": "Training Agents to Detect Early Cognitive Decline", "col": "1", "col_label": "Project", "label": "Training Agents to Detect Early Cognitive Decline", "legend_label": "Training Agents to Detect Early Cognitive Decline", "order": "4", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "224438", "display_total": "$224,438", "subtitle": "$224,438 in", "subtitle2": "", "scaled_in": "224438", "scaled_out": "25000", "node_value": "224438", "full_title": "Training Artificial Intelligence Conversational Agents to Detect Early Cognitive Decline", "college": "Arts & Sciences", "department": "Psychological Sciences", "pi": "Heather Bailey"}, {"name": "External awards ($)", "col": "2", "col_label": "Outcome type", "label": "External awards ($)", "legend_label": "Funding", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "2769401", "display_total": "$2,769,401", "subtitle": "3 awards", "subtitle2": "$2,769,401 total", "scaled_in": "2769401", "scaled_out": "0", "node_value": "2769401", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Publications", "col": "2", "col_label": "Outcome type", "label": "Publications", "legend_label": "Publications", "order": "1", "color": "#AC9766", "text_color": "#835d09", "unit": "Publications", "true_total": "9", "display_total": "9 Publications", "subtitle": "9 publications", "subtitle2": "", "scaled_in": "45000", "scaled_out": "0", "node_value": "45000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Presentations", "col": "2", "col_label": "Outcome type", "label": "Presentations", "legend_label": "Presentations", "order": "2", "color": "#F4C55C", "text_color": "#000000", "unit": "Presentations", "true_total": "91", "display_total": "91 Presentations", "subtitle": "91 presentations", "subtitle2": "", "scaled_in": "455000", "scaled_out": "0", "node_value": "455000", "full_title": "", "college": "", "department": "", "pi": ""}], "links": [{"source": "One K-State Fund", "target": "AI-based Molecular Image Analysis", "value": "61561", "trueValue": "61561", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI-based Molecular Image Analysis", "value": "117688", "trueValue": "117688", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "45453", "trueValue": "45453", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "54903", "trueValue": "54903", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Training Agents to Detect Early Cognitive Decline", "value": "117358", "trueValue": "117358", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Training Agents to Detect Early Cognitive Decline", "value": "107080", "trueValue": "107080", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "124628", "trueValue": "124628", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "123545", "trueValue": "123545", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "AI for Pollinators: Developing a Global Hub", "value": "151000", "trueValue": "151000", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI for Pollinators: Developing a Global Hub", "value": "96475", "trueValue": "96475", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "AI-based Molecular Image Analysis", "target": "External awards ($)", "value": "1000000", "trueValue": "1000000", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Publications", "value": "20000", "trueValue": "4", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Presentations", "value": "55000", "trueValue": "11", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Publications", "value": "5000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Presentations", "value": "330000", "trueValue": "66", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Publications", "value": "5000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Presentations", "value": "20000", "trueValue": "4", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Presentations", "value": "15000", "trueValue": "3", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "External awards ($)", "value": "1769401", "trueValue": "1769401", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Publications", "value": "15000", "trueValue": "3", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Presentations", "value": "35000", "trueValue": "7", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}]}];
const DEFAULT_VIEW = "all";
VIEWS.forEach(v => {
v.nodes.forEach(n => {
// The authored column is `col`, NOT `layer`. d3-sankey's computeNodeLayers
// overwrites node.layer with the column index of the graph it was passed,
// so anything reading .layer after layout gets the wrong answer. Colours
// and column heads key off `col`, which the library never touches.
n.col = +n.col;
n.order = +n.order;
n.true_total = +n.true_total;
n.scaled_in = +n.scaled_in;
n.scaled_out = +n.scaled_out;
n.node_value = +n.node_value;
});
v.links.forEach(l => {
// value is dollar-equivalents and is what d3 lays out. trueValue is the
// real number in the link's own unit and is the ONLY one a reader sees.
l.value = +l.value;
l.trueValue = +l.trueValue;
});
});
// The active view and its dollars-per-count knob. Both are reassigned by
// applyView() when the slicer is clicked; render() and the tooltips read them.
let viewIndex = Math.max(0, VIEWS.findIndex(v => v.key === DEFAULT_VIEW));
let DATA = VIEWS[viewIndex];
let D = DATA.d;
// Minimum drawn ribbon width, px. Enforced by widening the LINK and restacking
// (see liftLinks), never by clamping stroke-width -- a clamped stroke makes a
// ribbon overhang the node face it lands on, which is the mismatch the floor
// was meant to cure.
const W_MIN = 2;
// One step of type size in px, matching --type-step in the stylesheet. The
// layout constants that depend on text height are derived from it below
// rather than fixed, so raising it does not crowd the labels.
const TYPE_STEP = 6;
const NODE_W = 22;
const COLUMN_TITLES = ["Funding sources", "Projects", "Outcomes"];
// The chart-height floor, in px. MUST equal #chart's CSS min-height. Because
// the floor lives in CSS too, box.height is already >= CHART_MIN_H, so H ===
// the chart box's own height and the svg fills it exactly.
const CHART_MIN_H = 480;
// The exact string wrangle_load_gripex_csv.DOLLAR_UNIT carries, inlined so a
// change of wording or case there cannot silently turn every dollar link into
// a count here. Compare against this, never against a literal.
const DOLLAR_UNIT = "Dollars";
const usd = d3.format("$,.0f");
const int = d3.format(",d");
const tooltip = d3.select("#tooltip");
// Sentence case for a unit shown as a label ("Unit: Publications"),
// leaving the stored lowercase form intact for "66 presentations".
function capitalize(s) {
const t = String(s === undefined || s === null ? "" : s);
return t.charAt(0).toUpperCase() + t.slice(1);
}
// The one place a link's number is turned into text. Always trueValue --
// never `value`, which is dollar-equivalents for layout only.
//
// Uses the SHORT unit, matching the value line under the layer-2 nodes:
// "3 publications", "$1,769,401 total". Funding links carry no short unit,
// so they come out as bare money.
function fmtShort(trueValue, unit, shortUnit) {
const n = unit === DOLLAR_UNIT ? usd(trueValue) : int(trueValue);
const u = countUnit(trueValue, unit, shortUnit);
return u ? n + " " + u : n;
}
// A count of exactly one takes the singular: "1 publication", not
// "1 publications". The short units are stored as regular plurals, so
// "-ies" -> "-y" and a dropped final "s" cover them. Dollar units ("total")
// are never touched.
function countUnit(trueValue, unit, shortUnit) {
if (!shortUnit || unit === DOLLAR_UNIT || trueValue !== 1) return shortUnit;
return shortUnit.replace(/ies$/, "y").replace(/s$/, "");
}
function copyNodes(list) { return list.map(d => Object.assign({}, d)); }
function copyLinks(list) { return list.map(d => Object.assign({}, d)); }
// Wrap to at most maxLines of maxChars, ellipsing whatever will not fit.
// Three of the five project labels run 49-60 characters, so this is load
// bearing rather than defensive.
function wrapLines(text, maxChars, maxLines) {
const words = String(text).split(/\s+/).filter(Boolean);
const lines = [];
let cur = "";
for (const w of words) {
const candidate = cur ? cur + " " + w : w;
if (candidate.length <= maxChars) { cur = candidate; continue; }
if (cur) lines.push(cur);
cur = w;
if (lines.length === maxLines) break;
}
if (cur && lines.length < maxLines) lines.push(cur);
if (!lines.length) return [""];
// Anything left over -- either a word longer than the budget or more words
// than the lines allow -- is ellipsed onto the final line.
const consumed = lines.join(" ").length;
const overflow = words.join(" ").length > consumed;
const last = lines[lines.length - 1];
if (overflow || last.length > maxChars) {
lines[lines.length - 1] =
last.slice(0, Math.max(1, maxChars - 1)).trimEnd() + "…";
}
return lines;
}
function render() {
d3.select("#chart").selectAll("*").remove();
// Round the measured box: getBoundingClientRect returns fractional px, and a
// fractional svg height set display:block can sit a sub-pixel proud of its
// flex container -- enough for the iframe to grow a scrollbar that vanishes
// the moment any reflow re-rounds it. Integer height, integer box, no gap.
// The floor equals #chart's CSS min-height (CHART_MIN_H), so box.height is
// already >= it and this Math.max never inflates H past the box.
const box = document.getElementById("chart").getBoundingClientRect();
const W = Math.max(820, Math.round(box.width));
const H = Math.max(CHART_MIN_H, Math.round(box.height));
// Gutters for the fund labels (left) and the outcome labels (right). The
// project labels do NOT get a gutter -- see below.
const mLeft = Math.round(Math.min(190, Math.max(110, W * 0.13)));
const mRight = Math.round(Math.min(210, Math.max(130, W * 0.15)));
const mTop = 34;
// px per character, measured at the base 11.5px node label and scaled with
// the type step. A stale divisor here silently over-fills the gutters.
const CHAR_W = 6.2 * (11.5 + TYPE_STEP) / 11.5;
const PROJ_CHAR_W = 6.0 * (11.5 + TYPE_STEP) / 11.5;
const fundChars = Math.floor(mLeft / CHAR_W);
const outChars = Math.floor(mRight / CHAR_W);
// The svg is created before the plot area is sized, because the height of
// the bottom band depends on whether the legend and the scale sentence fit
// on one row -- and that can only be known by measuring rendered text.
const svg = d3.select("#chart").append("svg")
.attr("width", W).attr("height", H)
.attr("viewBox", [0, 0, W, H])
.attr("font-family", "inherit");
const defs = svg.append("defs");
// ------------------------------------------------------- bottom band
// One number for the page margin, shared with the header via CSS so the
// legend's right edge and the scale sentence's left edge line up with the
// text above them.
const LEGEND_INSET = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--page-inset")
) || 28;
const SW = 11, GAP = 7, ITEM_GAP = 20, ROW = 18 + TYPE_STEP;
const CAPTION = "";
const legendNodes = DATA.nodes
.filter(n => n.col === 2)
.sort((a, b) => a.order - b.order);
// Measure in a hidden group, then discard it. Widths are measured rather
// than estimated from character counts because the labels vary a lot in
// width and a wrong guess here silently overlaps two runs of text.
const probe = svg.append("g").attr("visibility", "hidden");
const itemW = legendNodes.map(n => {
const t = probe.append("text").attr("class", "legend-text")
.text(n.legend_label);
return SW + GAP + t.node().getComputedTextLength();
});
probe.remove();
// Pack the swatches into as many rows as the page width needs. At the base
// type they fit on one; three steps up they do not at narrow widths, and a
// single row would run off the left edge.
const legendAvail = W - 2 * LEGEND_INSET;
const legendRowsPacked = [];
let rowItems = [], rowW = 0;
itemW.forEach((w, i) => {
const added = rowItems.length ? ITEM_GAP + w : w;
if (rowItems.length && rowW + added > legendAvail) {
legendRowsPacked.push({ items: rowItems, w: rowW });
rowItems = []; rowW = 0;
}
rowW += rowItems.length ? ITEM_GAP + w : w;
rowItems.push(i);
});
if (rowItems.length) legendRowsPacked.push({ items: rowItems, w: rowW });
const legendH = 14 + legendRowsPacked.length * ROW + (CAPTION ? ROW : 0);
const mBottom = 14 + legendH;
const innerW = W - mLeft - mRight;
const innerH = H - mTop - mBottom;
const root = svg.append("g").attr("transform", `translate(${mLeft},${mTop})`);
// Centre-to-centre label spacing is (h_i + h_i+1)/2 + nodePad, and the
// thinnest faces contribute almost nothing to it. With every node carrying
// a name and a value line, the padding does nearly all the separating.
// Centre-to-centre label spacing is (h_i + h_i+1)/2 + nodePad, and the
// thinnest faces contribute almost nothing to it. The padding does nearly
// all the separating, so it has to grow with the type or bigger labels
// collide.
const nodePad = Math.max(10, Math.min(22 + TYPE_STEP,
innerH / Math.max(20, 30 - TYPE_STEP)));
// A node face is drawn as the exact sum of the ribbon widths touching it,
// never as d3's raw y0..y1. That is what keeps a face and its ribbons equal
// after liftLinks has widened some of them.
const faceIn = n => d3.sum(n.targetLinks, l => l.width);
const faceOut = n => d3.sum(n.sourceLinks, l => l.width);
const faceH = n => Math.max(faceIn(n), faceOut(n));
const faceBottom = n => n.y0 + faceH(n);
// A floor on the equal gap, so a column can never pack flush to the edges
// even at the smallest window. Non-binding at every size tested.
const GAP_MIN = 6;
// Widen any ribbon under the floor, then let d3 restack the endpoints from
// the new widths so faces and ribbons stay equal by construction. At
// D = 8000 this catches the three 1-count links, which land near 1.3 px.
function liftLinks(graph) {
graph.links.forEach(l => {
if (l.width > 0 && l.width < W_MIN) l.width = W_MIN;
});
}
// Sum of the DRAWN face heights in each column. This is what a column
// actually occupies once liftLinks has widened the thin ribbons -- d3's own
// y1-y0 understates it, which is how nodes came to overlap.
function columnFaceTotals(graph) {
return d3.rollup(graph.nodes, v => d3.sum(v, faceH), n => n.col);
}
// Equal whitespace, per column: every gap is the same size, INCLUDING the
// one above the first node and the one below the last. So a column of n
// nodes has n+1 identical gaps.
//
// This replaces d3's own stacking for the vertical axis. d3 spaces nodes by
// nodePadding measured against y1-y0, but the face this project draws is
// the sum of the ribbon widths touching the node, and after liftLinks that
// is LARGER than y1-y0. The two disagreed, and the faces at the top of a
// column overlapped. Distributing the real face heights makes overlap
// impossible by construction rather than by tuning a pad.
function repack(gen, graph) {
for (const members of d3.group(graph.nodes, n => n.col).values()) {
members.sort((a, b) => a.order - b.order);
const used = d3.sum(members, faceH);
const gap = (innerH - used) / (members.length + 1);
let y = gap;
for (const n of members) {
const h = faceH(n);
n.y0 = y;
n.y1 = y + h; // keep d3's own bookkeeping consistent
y += h + gap;
}
}
// computeLinkBreadths: restacks every link endpoint from its node's y0,
// so ribbons follow the moved faces exactly.
gen.update(graph);
}
// Widening overflows a column that already filled the canvas, so lay out,
// measure the overflow, and lay out again on a shorter extent. The measure
// is the TALLEST COLUMN'S face total plus one minimum gap per boundary --
// not the bottom of the deepest node, because repack() is what sets the
// bottom now.
function fitPass(gen, nodeList, linkList) {
let h = innerH, graph;
for (let pass = 0; pass < 4; pass++) {
gen.extent([[0, 0], [innerW, h]]);
graph = gen({ nodes: copyNodes(nodeList), links: copyLinks(linkList) });
liftLinks(graph);
gen.update(graph);
let over = 0;
for (const [col, used] of columnFaceTotals(graph)) {
const n = graph.nodes.filter(x => x.col === col).length;
over = Math.max(over, used + (n + 1) * GAP_MIN - innerH);
}
if (over <= 0.5) break;
h -= over;
}
repack(gen, graph);
return graph;
}
// ONE pass, one global scale. Every link already carries dollar-equivalents,
// so d3's single value->pixel factor renders every real dollar ribbon
// proportional to every other with no further work. Do not split this.
const sankey = d3.sankey()
.nodeId(d => d.name)
.nodeWidth(NODE_W)
.nodePadding(nodePad)
.nodeSort((a, b) => a.order - b.order)
.linkSort(null);
const graph = fitPass(sankey, DATA.nodes, DATA.links);
const byName = new Map(graph.nodes.map(n => [n.name, n]));
// Column x positions, read back from the layout rather than assumed.
const colX = new Map();
graph.nodes.forEach(n => { if (!colX.has(n.col)) colX.set(n.col, n.x0); });
// ------------------------------------------------------------ gradients
// Left-to-right, source-node colour to target-node colour. The colour of a
// node is carried in the data (nodes_v1.csv), so the "purple = dollars"
// convention -- purple at BOTH ends, funds and the dollar outcome -- needs
// no special case here.
graph.links.forEach((l, i) => {
l.gradientId = `grad-${i}`;
const grad = defs.append("linearGradient")
.attr("id", l.gradientId)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", l.source.x1).attr("x2", l.target.x0);
grad.append("stop").attr("offset", "0%").attr("stop-color", l.source.color);
grad.append("stop").attr("offset", "100%").attr("stop-color", l.target.color);
});
// ---------------------------------------------------------------- links
const linkPath = d3.sankeyLinkHorizontal();
const linkSel = root.append("g").attr("class", "links")
.selectAll("path").data(graph.links).join("path")
.attr("class", "link")
.attr("d", linkPath)
.attr("stroke", d => `url(#${d.gradientId})`)
.attr("stroke-width", d => d.width)
.on("mousemove", (event, d) => showTooltip(event, linkTooltip(d)))
.on("mouseleave", hideTooltip);
// ---------------------------------------------------------------- nodes
// A node's left edge is the exact sum of the ribbons arriving, its right
// edge the exact sum of those leaving. For funds and outcomes one side is
// empty and the shape is a rectangle. For a project the two differ -- every
// one of the five -- and the wedge says so rather than hiding the gap
// inside a rectangle sized to the larger side.
function nodePath(n) {
const hIn = faceIn(n), hOut = faceOut(n);
const left = hIn > 0 ? hIn : hOut;
const right = hOut > 0 ? hOut : hIn;
return `M${n.x0},${n.y0} L${n.x1},${n.y0} `
+ `L${n.x1},${n.y0 + right} L${n.x0},${n.y0 + left} Z`;
}
const shapeG = root.append("g").attr("class", "nodes");
// One <g class="node"> per node, holding the drawn shape and its hit area.
// The wrapper is what CSS hovers and what drag raises to the front.
const nodeG = shapeG.selectAll("g.node").data(graph.nodes).join("g")
.attr("class", "node");
const nodeSel = nodeG.append("path")
.attr("class", "node-shape")
.attr("d", nodePath)
.attr("fill", d => d.color)
.attr("pointer-events", "none");
// The thinnest outcome node is a couple of pixels tall. Its drawn geometry
// stays honest; a transparent hit area gives the pointer something to grab.
const HIT_MIN = 11;
function hitY(d) { return d.y0 - Math.max(0, (HIT_MIN - faceH(d)) / 2); }
const hitSel = nodeG.append("rect")
.attr("class", "node-hit")
.attr("x", d => d.x0).attr("y", d => hitY(d))
.attr("width", NODE_W).attr("height", d => Math.max(HIT_MIN, faceH(d)))
.on("mousemove", (event, d) => showTooltip(event, nodeTooltip(d)))
.on("mouseleave", hideTooltip);
// --------------------------------------------------------------- labels
const LH = 11 + TYPE_STEP; // baseline-to-baseline within a label block
const labelG = root.append("g").attr("class", "labels");
function labelY(d) { return d.y0 + faceH(d) / 2; }
// Project labels have no gutter of their own -- they overlay the funding
// ribbon zone to the LEFT of the project column, right-anchored, with a
// white halo so the ribbons read behind them. The left zone carries 10
// ribbons against the outcome side's 16 (including the dominant dollar
// one), so it is the quieter side to write on.
const projX = colX.get(1);
const projSpace = Math.max(140, projX - NODE_W - 26);
const projChars = Math.floor(projSpace / PROJ_CHAR_W);
// valueFn may return one string or two. The second line exists for an
// outcome that was MERGED into this node instead of getting a node of its
// own -- "$1,769,401 total" over "2 awards" on External awards ($). It is
// authored in Python (nodes_*.csv `subtitle2`), empty on every other node,
// and it has to be counted into the block height or the label centres drift.
function addLabels(data, xFn, anchor, nameFn, valueFn, charsFn) {
const g = labelG.append("g");
data.forEach(d => {
const lines = wrapLines(nameFn(d), charsFn(d), 2);
const values = [].concat(valueFn(d)).filter(v => v);
const total = lines.length + values.length;
const top = labelY(d) - ((total - 1) * LH) / 2;
const cell = g.append("g").attr("class", "label-cell").datum(d);
lines.forEach((line, i) => {
cell.append("text")
.attr("class", "node-label")
.attr("x", xFn(d)).attr("y", top + i * LH)
.attr("dy", "0.32em")
.attr("text-anchor", anchor)
.text(line);
});
values.forEach((v, i) => {
cell.append("text")
.attr("class", "node-value")
.attr("x", xFn(d)).attr("y", top + (lines.length + i) * LH)
.attr("dy", "0.32em")
.attr("text-anchor", anchor)
.attr("fill", valueFill(d))
.text(v);
});
});
return g;
}
// Four of the six diagram colours are below the WCAG AA floor as type.
// Fills keep their spec colour; the value line uses the palette's AA-safe
// stand-in, which the data carries as text_color.
function valueFill(d) { return d.col === 2 ? d.text_color : "#666"; }
const funds = graph.nodes.filter(n => n.col === 0);
const projects = graph.nodes.filter(n => n.col === 1);
const outcomes = graph.nodes.filter(n => n.col === 2)
.sort((a, b) => a.order - b.order);
// The value line is authored in Python (nodes_*.csv `subtitle`) so the copy
// lives in one place. Outcomes use the SHORT unit -- the node's own name is
// directly above it, so "13 proposal submissions" said it twice.
addLabels(funds, d => d.x0 - 9, "end",
d => d.label, d => d.subtitle, () => fundChars);
addLabels(projects, d => d.x0 - 9, "end",
d => d.label, d => d.subtitle, () => projChars);
addLabels(outcomes, d => d.x1 + 9, "start",
d => d.label, d => [d.subtitle, d.subtitle2], () => outChars);
// --------------------------------------------------------- column heads
root.append("g").selectAll("text")
.data([0, 1, 2].map(c => ({ x: colX.get(c) + NODE_W / 2, t: COLUMN_TITLES[c] })))
.join("text")
.attr("class", "col-head")
.attr("x", d => d.x).attr("y", -14)
.attr("text-anchor", "middle")
.text(d => d.t);
// --------------------------------------------------------------- legend
// Swatch and name only -- the unit was dropped on user instruction
// 2026-08-21. The unit is still on screen: every outcome node's value line
// reads "91 presentations" / "$1,769,401".
//
// Right-aligned to the page margin and pinned to the bottom of the canvas,
// wrapping upward into extra rows when the labels do not fit one.
const legendG = svg.append("g").attr("class", "legend");
const baseY = H - 14;
legendRowsPacked.forEach((row, r) => {
// Rows stack upward from the bottom, so the first row reads highest.
const y = baseY - (legendRowsPacked.length - 1 - r) * ROW;
const g = legendG.append("g")
.attr("transform", `translate(${W - LEGEND_INSET - row.w},${y})`);
let x = 0;
row.items.forEach(i => {
const n = outcomes[i];
const item = g.append("g").attr("transform", `translate(${x},0)`);
item.append("rect")
.attr("width", SW).attr("height", SW).attr("y", -SW + 1)
.attr("rx", 2)
.attr("fill", n.color);
item.append("text")
.attr("class", "legend-text")
.attr("x", SW + GAP).attr("y", 0)
.text(n.legend_label); // legend copy, not the node label
x += itemW[i] + ITEM_GAP;
});
});
// The brief requires this caption verbatim; it is emitted from a Python
// constant that can be set empty, in which case draw nothing rather than an
// empty <text>. It sits above every legend row.
if (CAPTION) {
legendG.append("text")
.attr("class", "legend-caption")
.attr("x", LEGEND_INSET)
.attr("y", baseY - legendRowsPacked.length * ROW)
.text(CAPTION);
}
// The scale sentence lives in the HTML footer, on the right, opposite the
// hover hint -- not in the SVG. It is rewritten on every render because the
// px-per-dollar figure is only true for the size just laid out.
d3.select("#scale-note").text(scaleSentence());
// Quote the honest scale: a lifted ribbon sits above the true px-per-dollar,
// so the minimum over the links is the factor the rest of them share.
function scaleSentence() {
const ky = d3.min(graph.links, l => l.width / l.value);
const perPx = 1 / ky;
return `Ribbon width is dollars throughout: 1 px ≈ ${usd(perPx)}. `
+ `Count ribbons are scaled for visibility: One count is drawn as ${usd(D)}.`;
}
// ----------------------------------------------------------------- drag
function refresh() {
sankey.update(graph);
linkSel.attr("d", linkPath).attr("stroke-width", d => d.width);
graph.links.forEach(l => {
defs.select(`#${l.gradientId}`)
.attr("x1", l.source.x1).attr("x2", l.target.x0);
});
nodeSel.attr("d", nodePath);
hitSel.attr("y", d => hitY(d)).attr("height", d => Math.max(HIT_MIN, faceH(d)));
// g.label-cell, NOT g: labelG also holds three per-column wrapper groups
// that carry no datum, and labelY(undefined) would throw.
labelG.selectAll("g.label-cell").each(function (d) {
// Mirror addLabels' geometry exactly. This used to read
// top = labelY(d) - (lines * LH) / 2 // lines = NAME lines only
// and set every value line to one shared y, which was right only while
// every cell had exactly one value line. With a merged outcome drawing
// two, that block re-centred half a leading too high on the first drag
// and its two value lines collapsed onto each other. Both were silent
// until someone dragged a node.
const cell = d3.select(this);
const nameSel = cell.selectAll("text.node-label");
const valSel = cell.selectAll("text.node-value");
const total = nameSel.size() + valSel.size();
const top = labelY(d) - ((total - 1) * LH) / 2;
nameSel.attr("y", (_, i) => top + i * LH);
valSel.attr("y", (_, i) => top + (nameSel.size() + i) * LH);
});
}
// Clamped against the DRAWN face, which after liftLinks is no longer d3's
// y0..y1.
const drag = d3.drag()
.subject(d => ({ y: d.y0 }))
.on("start", function () {
const g = this.parentNode; // the <g class="node"> wrapper
g.parentNode.appendChild(g);
})
.on("drag", function (event, d) {
const h = faceH(d);
d.y0 = Math.max(0, Math.min(innerH - h, event.y));
d.y1 = d.y0 + h;
refresh();
});
hitSel.call(drag);
// -------------------------------------------------------------- tooltips
// trueValue and unit only. The scaled `value` is layout arithmetic and is
// never shown to a reader.
function linkTooltip(d) {
let body = `<div class="tt-title">${esc(d.source.label)} → ${esc(d.target.label)}</div>`
+ row(d.link_group === "funding" ? "Funding" : "Outcome",
fmtShort(d.trueValue, d.unit, d.short_unit));
body += d.unit === DOLLAR_UNIT
? `<div class="tt-meta">Dollars, drawn at the same scale as every other dollar ribbon.</div>`
: `<div class="tt-meta">A count, drawn at ${usd(D)} per unit so it is visible beside the dollars. Its width is not comparable to a dollar ribbon.</div>`;
return body;
}
function nodeTooltip(n) {
let body = `<div class="tt-title">${esc(n.label)}</div>`;
if (n.col === 0) {
body += row("Dispensed", usd(n.true_total));
body += row("Projects funded",
int(graph.links.filter(l => l.source.name === n.name).length));
} else if (n.col === 1) {
body += row("Funding in", usd(n.true_total));
const outs = graph.links.filter(l => l.source.name === n.name);
outs.forEach(l => {
body += row(l.target.label,
fmtShort(l.trueValue, l.unit, l.short_unit));
});
body += `<div class="tt-meta"><strong>${esc(n.full_title)}</strong>`
+ `<br>${esc(n.college)} · ${esc(n.department)}`
+ `<br>PI: ${esc(n.pi)}</div>`;
} else {
body += row("Total", n.display_total);
body += row("Projects contributing",
int(graph.links.filter(l => l.target.name === n.name).length));
body += `<div class="tt-meta">Unit: ${esc(capitalize(n.unit))}.</div>`;
}
return body;
}
function row(k, v) {
return `<div class="tt-row"><span class="tt-key">${esc(k)}</span><span>${v}</span></div>`;
}
}
function esc(s) {
return String(s === undefined || s === null ? "" : s)
.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
// Viewport coordinates (clientX/clientY) + getBoundingClientRect, because a
// position:fixed element is placed in the viewport frame, not the document.
// The old pageX/pageY pair would mis-place the tooltip inside a scrolled iframe.
function showTooltip(event, html) {
const pad = 14;
tooltip.html(html).style("display", "block");
const bb = tooltip.node().getBoundingClientRect();
let x = event.clientX + pad, y = event.clientY - pad;
if (x + bb.width > window.innerWidth) x = event.clientX - bb.width - pad;
if (y + bb.height > window.innerHeight) y = window.innerHeight - bb.height - 6;
tooltip.style("left", x + "px").style("top", Math.max(6, y) + "px");
}
function hideTooltip() { tooltip.style("display", "none"); }
// -------------------------------------------------------------- view slicer
// Swap in the active view's data + D, update the button label, and relayout.
function applyView() {
DATA = VIEWS[viewIndex];
D = DATA.d;
d3.select("#slicer-label").text(DATA.label);
render();
}
// One button, cycling All -> Awards pending -> Awards secured -> All.
d3.select("#slicer").on("click", () => {
viewIndex = (viewIndex + 1) % VIEWS.length;
applyView();
});
applyView();
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(render, 250);
});
console.log(`gripex multi-unit sankey: ${DATA.nodes.length} nodes, ${DATA.links.length} links, D=${D}`);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>GRIPex: AI in the Disciplines</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<style>
/* Mobile companion to gripex_sankey_v25.html. Same data, same palette, same
d3-sankey layout engine. What changes for a phone:
- The chart keeps the single left-to-right flow but is drawn tall, and
the PAGE scrolls (the desktop pins everything to 100vh).
- Every label sits on top of its own bar, inside one of three vertical
lanes (left, centre, right). Lanes never overlap, and within a lane
the packing reserves each label's height, so no two labels can
collide at any width.
- Hover tooltips become a tap-to-open bottom sheet, and the tapped node
or ribbon is brought forward while the rest dims.
- The cycling slicer becomes a three-way segmented control that sticks
to the top while the chart scrolls.
- Node dragging is dropped: on a touch screen it fights page scroll. */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--page-inset: 16px;
--purple: #512888;
--wash: #F3EEF9;
--rule: #D1D1D1;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { font-family: var(--font); background: #FFFFFF; color: #000000; }
header { padding: 18px var(--page-inset) 14px; }
h1 {
font-size: 22px; line-height: 1.2; font-weight: 650; color: var(--purple);
letter-spacing: -0.01em; text-wrap: balance;
}
.subtitle { margin-top: 6px; font-size: 15px; line-height: 1.4; color: #2a2a2a; }
.scale-note {
margin-top: 10px; font-size: 13.5px; line-height: 1.4; font-weight: 600;
color: var(--purple); background: var(--wash);
border-left: 3px solid var(--purple); padding: 7px 10px; border-radius: 2px;
}
/* ------------------------------------------------------- view switcher */
.views-bar {
position: sticky; top: 0; z-index: 5; background: #FFFFFF;
padding: 8px var(--page-inset); border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
}
/* Flex, not equal columns: "All" is short, so it gives its share of the
width to the two longer labels and they stay on one line. */
.views {
display: flex; gap: 2px;
padding: 2px; border: 1px solid var(--purple); border-radius: 999px;
background: var(--wash);
}
.views button {
flex: 1 1 auto; min-width: 0;
min-height: 44px; padding: 4px 12px; border: 0; border-radius: 999px;
background: transparent; color: var(--purple);
font: 600 13.5px/1.15 var(--font); cursor: pointer;
-webkit-tap-highlight-color: transparent; transition: background 0.12s, color 0.12s;
}
.views button[aria-pressed="true"] { background: var(--purple); color: #FFFFFF; }
.views button:focus-visible { outline: 2px solid var(--purple); outline-offset: 2px; }
/* --------------------------------------------------------------- chart */
#chart { padding-top: 10px; }
#chart svg {
display: block; font-family: inherit; overflow: visible;
-webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none;
}
/* Wide ribbons are filled bands, not thick strokes: on a narrow screen the
curves are steep, and a wide stroke on a steep curve folds over itself
and bulges past the bar it lands on. A band's two edges cannot. Thin
ribbons stay strokes, which keep their thickness through the bend. */
.link { cursor: pointer; transition: fill-opacity 0.15s, stroke-opacity 0.15s; }
.link.band { stroke: none; fill-opacity: 0.44; }
.link.line { fill: none; stroke-opacity: 0.44; }
.link-hit { fill: none; stroke: #000; stroke-opacity: 0; pointer-events: stroke; cursor: pointer; }
.node { cursor: pointer; transition: opacity 0.15s; outline: none; }
.node-shape { stroke: #FFFFFF; stroke-width: 1; }
.node-hit { fill: transparent; }
.node-ring { fill: none; stroke: none; }
.node:focus-visible .node-ring { stroke: var(--purple); stroke-width: 2; stroke-dasharray: 4 3; }
/* Selection: the chosen node or ribbon and everything it touches stay at
full strength; the rest steps back. */
svg.focus .link.band { fill-opacity: 0.1; }
svg.focus .link.line { stroke-opacity: 0.1; }
svg.focus .link.band.on { fill-opacity: 0.8; }
svg.focus .link.line.on { stroke-opacity: 0.8; }
svg.focus .node { opacity: 0.3; }
svg.focus .node.on { opacity: 1; }
.node.picked .node-shape { stroke: var(--purple); stroke-width: 1.5; }
.lbl, .col-head {
pointer-events: none; paint-order: stroke; stroke: #FFFFFF; stroke-width: 3px;
stroke-linecap: round; stroke-linejoin: round;
}
.lbl-name { font-weight: 600; fill: #1a1a1a; }
.col-head { font-weight: 700; fill: var(--purple); letter-spacing: 0.09em; text-transform: uppercase; }
/* -------------------------------------------------------------- legend */
.legend {
display: flex; flex-wrap: wrap; gap: 6px 18px;
padding: 6px var(--page-inset) 0; font-size: 13.5px; color: #333;
}
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
.legend-item i { width: 11px; height: 11px; border-radius: 2px; display: inline-block; }
footer {
margin-top: 14px; padding: 10px var(--page-inset) calc(18px + env(safe-area-inset-bottom));
border-top: 1px solid var(--rule);
font-size: 12.5px; line-height: 1.45; color: #595959;
display: flex; flex-direction: column; gap: 4px;
}
/* --------------------------------------------------------- detail sheet */
.sheet {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
max-height: min(62vh, 560px); display: flex; flex-direction: column;
background: #FFFFFF; border-radius: 16px 16px 0 0;
box-shadow: 0 -6px 28px rgba(20, 10, 32, 0.24);
transform: translateY(105%); visibility: hidden;
transition: transform 0.22s ease-out, visibility 0s linear 0.22s;
}
.sheet.open { transform: none; visibility: visible; transition: transform 0.22s ease-out; }
.sheet-grip { width: 36px; height: 4px; border-radius: 2px; background: #D1D1D1; margin: 8px auto 0; flex-shrink: 0; }
.sheet-head {
display: flex; align-items: flex-start; gap: 8px; flex-shrink: 0;
padding: 2px 6px 10px var(--page-inset); border-bottom: 1px solid #ECE6F3;
}
.sheet-title {
flex: 1; min-width: 0; padding-top: 10px;
font-size: 16.5px; line-height: 1.3; font-weight: 700; color: var(--purple);
}
.sheet-close {
flex-shrink: 0; width: 44px; height: 44px; border: 0; border-radius: 999px;
background: transparent; color: #333; display: grid; place-items: center; cursor: pointer;
}
.sheet-close:focus-visible { outline: 2px solid var(--purple); outline-offset: -2px; }
.sheet-body {
overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
padding: 2px var(--page-inset) calc(18px + env(safe-area-inset-bottom));
}
.sheet-section {
margin-top: 14px; font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--purple);
}
.sheet-row {
display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
padding: 9px 0; border-bottom: 1px solid #EEEEEE; font-size: 14.5px; line-height: 1.35;
}
.sheet-row .k { color: #333; min-width: 0; }
.sheet-row .k i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 7px; }
.sheet-row .v { font-weight: 600; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.sheet-row.total .k, .sheet-row.total .v { font-weight: 700; color: #000; }
.sheet-meta { margin-top: 12px; font-size: 13.5px; line-height: 1.5; color: #444; }
.sheet-meta strong { color: #1a1a1a; }
@media (prefers-reduced-motion: reduce) {
.sheet, .sheet.open, .link, .node, .views button { transition: none; }
}
</style>
</head>
<body>
<header>
<h1>GRIPex: AI in the Disciplines</h1>
<p class="subtitle">See the outcomes of funding game-changing exploratory Artificial Intelligence (AI) research.</p>
<p class="scale-note">Ribbon width = Dollars in and dollars out. Count ribbons scales converted for comparison.</p>
</header>
<nav class="views-bar" aria-label="Chart view">
<div class="views" id="views" role="group" aria-label="Show"></div>
</nav>
<main>
<div id="chart"></div>
<div class="legend" id="legend" aria-label="Outcome colours"></div>
</main>
<footer>
<span>Tap a bar or ribbon for detail</span>
<span id="scale-note"></span>
</footer>
<section id="sheet" class="sheet" role="dialog" aria-modal="false" aria-labelledby="sheet-title" aria-hidden="true">
<div class="sheet-grip" aria-hidden="true"></div>
<div class="sheet-head">
<h2 id="sheet-title" class="sheet-title"></h2>
<button id="sheet-close" class="sheet-close" type="button" aria-label="Close details">
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 5l10 10M15 5L5 15"/></svg>
</button>
</div>
<div id="sheet-body" class="sheet-body"></div>
</section>
<script>
// ------------------------------------------------------------------- data
// Copied verbatim from gripex_sankey_v25.html: three precomputed views, each
// with its own nodes, links and D (dollars drawn per count). Regenerate both
// files from the same Phase 2 output so they never drift.
const VIEWS = [{"key": "all", "label": "All", "d": 8000, "nodes": [{"name": "One K-State Fund", "col": "0", "col_label": "Funding source", "label": "One K-State Fund", "legend_label": "One K-State Fund", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "500000", "display_total": "$500,000 across 5 projects", "subtitle": "$500,000", "subtitle2": "", "scaled_in": "0", "scaled_out": "500000", "node_value": "500000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Match Fund (OVPR)", "col": "0", "col_label": "Funding source", "label": "Match Fund (OVPR)", "legend_label": "Match Fund (OVPR)", "order": "1", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "499691", "display_total": "$499,691 across 5 projects", "subtitle": "$499,691", "subtitle2": "", "scaled_in": "0", "scaled_out": "499691", "node_value": "499691", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "AI for Pollinators: Developing a Global Hub", "col": "1", "col_label": "Project", "label": "AI for Pollinators: Developing a Global Hub", "legend_label": "AI for Pollinators: Developing a Global Hub", "order": "0", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "247475", "display_total": "$247,475", "subtitle": "$247,475 in", "subtitle2": "", "scaled_in": "247475", "scaled_out": "3349380", "node_value": "3349380", "full_title": "AI for Pollinators: Developing a Global Hub for Collaboration, Data Sharing, and Pollinator Research", "college": "Agriculture", "department": "Entomology", "pi": "Brian Spiesman"}, {"name": "AI-based Molecular Image Analysis", "col": "1", "col_label": "Project", "label": "AI-based Molecular Image Analysis", "legend_label": "AI-based Molecular Image Analysis", "order": "1", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "179249", "display_total": "$179,249", "subtitle": "$179,249 in", "subtitle2": "", "scaled_in": "179249", "scaled_out": "1120000", "node_value": "1120000", "full_title": "AI-based Molecular Image Analysis for Ultrafast Laser Experiments", "college": "Arts & Sciences", "department": "Physics", "pi": "Daniel Rolles"}, {"name": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "col": "1", "col_label": "Project", "label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "legend_label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "order": "2", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "248173", "display_total": "$248,173", "subtitle": "$248,173 in", "subtitle2": "", "scaled_in": "248173", "scaled_out": "1024000", "node_value": "1024000", "full_title": "Mapping Seed-to-Plant Life Cycle to Predict Yields — Integrated Artificial Intelligence and Data Analytics Approach", "college": "Engineering", "department": "Biological and Agricultural Engineering", "pi": "Ajay Sharda"}, {"name": "Collaborating with AI: Writing Studies-Informed Applications", "col": "1", "col_label": "Project", "label": "Collaborating with AI: Writing Studies-Informed Applications", "legend_label": "Collaborating with AI: Writing Studies-Informed Applications", "order": "3", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "100356", "display_total": "$100,356", "subtitle": "$100,356 in", "subtitle2": "", "scaled_in": "100356", "scaled_out": "536000", "node_value": "536000", "full_title": "Collaborating with AI: A Project to Develop Writing Studies-Informed Applications of Generative AI for Teaching in Higher Education", "college": "Arts & Sciences", "department": "English", "pi": "Cydney Alexis"}, {"name": "Training Agents to Detect Early Cognitive Decline", "col": "1", "col_label": "Project", "label": "Training Agents to Detect Early Cognitive Decline", "legend_label": "Training Agents to Detect Early Cognitive Decline", "order": "4", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "224438", "display_total": "$224,438", "subtitle": "$224,438 in", "subtitle2": "", "scaled_in": "224438", "scaled_out": "40000", "node_value": "224438", "full_title": "Training Artificial Intelligence Conversational Agents to Detect Early Cognitive Decline", "college": "Arts & Sciences", "department": "Psychological Sciences", "pi": "Heather Bailey"}, {"name": "External awards ($)", "col": "2", "col_label": "Outcome type", "label": "External awards ($)", "legend_label": "Funding", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "2769401", "display_total": "$2,769,401", "subtitle": "3 awards", "subtitle2": "$2,769,401 total", "scaled_in": "2769401", "scaled_out": "0", "node_value": "2769401", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Publications", "col": "2", "col_label": "Outcome type", "label": "Publications", "legend_label": "Publications", "order": "1", "color": "#AC9766", "text_color": "#835d09", "unit": "Publications", "true_total": "9", "display_total": "9 Publications", "subtitle": "9 publications", "subtitle2": "", "scaled_in": "72000", "scaled_out": "0", "node_value": "72000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Presentations", "col": "2", "col_label": "Outcome type", "label": "Presentations", "legend_label": "Presentations", "order": "2", "color": "#F4C55C", "text_color": "#000000", "unit": "Presentations", "true_total": "91", "display_total": "91 Presentations", "subtitle": "91 presentations", "subtitle2": "", "scaled_in": "728000", "scaled_out": "0", "node_value": "728000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Pending awards ($)", "col": "2", "col_label": "Outcome type", "label": "Pending awards ($)", "legend_label": "Pending awards ($)", "order": "3", "color": "#C8A2C8", "text_color": "#000000", "unit": "Dollars", "true_total": "2499979", "display_total": "$2,499,979", "subtitle": "13 submissions", "subtitle2": "$2,499,979 total", "scaled_in": "2499979", "scaled_out": "0", "node_value": "2499979", "full_title": "", "college": "", "department": "", "pi": ""}], "links": [{"source": "One K-State Fund", "target": "AI-based Molecular Image Analysis", "value": "61561", "trueValue": "61561", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI-based Molecular Image Analysis", "value": "117688", "trueValue": "117688", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "45453", "trueValue": "45453", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "54903", "trueValue": "54903", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Training Agents to Detect Early Cognitive Decline", "value": "117358", "trueValue": "117358", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Training Agents to Detect Early Cognitive Decline", "value": "107080", "trueValue": "107080", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "124628", "trueValue": "124628", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "123545", "trueValue": "123545", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "AI for Pollinators: Developing a Global Hub", "value": "151000", "trueValue": "151000", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI for Pollinators: Developing a Global Hub", "value": "96475", "trueValue": "96475", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "AI-based Molecular Image Analysis", "target": "External awards ($)", "value": "1000000", "trueValue": "1000000", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Publications", "value": "32000", "trueValue": "4", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Presentations", "value": "88000", "trueValue": "11", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Publications", "value": "8000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Presentations", "value": "528000", "trueValue": "66", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Publications", "value": "8000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Presentations", "value": "32000", "trueValue": "4", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Presentations", "value": "24000", "trueValue": "3", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Pending awards ($)", "value": "1000000", "trueValue": "1000000", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "External awards ($)", "value": "1769401", "trueValue": "1769401", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Publications", "value": "24000", "trueValue": "3", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Presentations", "value": "56000", "trueValue": "7", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Pending awards ($)", "value": "1499979", "trueValue": "1499979", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}]}, {"key": "pending", "label": "Awards pending", "d": 5000, "nodes": [{"name": "One K-State Fund", "col": "0", "col_label": "Funding source", "label": "One K-State Fund", "legend_label": "One K-State Fund", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "500000", "display_total": "$500,000 across 5 projects", "subtitle": "$500,000", "subtitle2": "", "scaled_in": "0", "scaled_out": "500000", "node_value": "500000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Match Fund (OVPR)", "col": "0", "col_label": "Funding source", "label": "Match Fund (OVPR)", "legend_label": "Match Fund (OVPR)", "order": "1", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "499691", "display_total": "$499,691 across 5 projects", "subtitle": "$499,691", "subtitle2": "", "scaled_in": "0", "scaled_out": "499691", "node_value": "499691", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "AI for Pollinators: Developing a Global Hub", "col": "1", "col_label": "Project", "label": "AI for Pollinators: Developing a Global Hub", "legend_label": "AI for Pollinators: Developing a Global Hub", "order": "0", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "247475", "display_total": "$247,475", "subtitle": "$247,475 in", "subtitle2": "", "scaled_in": "247475", "scaled_out": "1549979", "node_value": "1549979", "full_title": "AI for Pollinators: Developing a Global Hub for Collaboration, Data Sharing, and Pollinator Research", "college": "Agriculture", "department": "Entomology", "pi": "Brian Spiesman"}, {"name": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "col": "1", "col_label": "Project", "label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "legend_label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "order": "1", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "248173", "display_total": "$248,173", "subtitle": "$248,173 in", "subtitle2": "", "scaled_in": "248173", "scaled_out": "1015000", "node_value": "1015000", "full_title": "Mapping Seed-to-Plant Life Cycle to Predict Yields — Integrated Artificial Intelligence and Data Analytics Approach", "college": "Engineering", "department": "Biological and Agricultural Engineering", "pi": "Ajay Sharda"}, {"name": "Collaborating with AI: Writing Studies-Informed Applications", "col": "1", "col_label": "Project", "label": "Collaborating with AI: Writing Studies-Informed Applications", "legend_label": "Collaborating with AI: Writing Studies-Informed Applications", "order": "2", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "100356", "display_total": "$100,356", "subtitle": "$100,356 in", "subtitle2": "", "scaled_in": "100356", "scaled_out": "335000", "node_value": "335000", "full_title": "Collaborating with AI: A Project to Develop Writing Studies-Informed Applications of Generative AI for Teaching in Higher Education", "college": "Arts & Sciences", "department": "English", "pi": "Cydney Alexis"}, {"name": "Training Agents to Detect Early Cognitive Decline", "col": "1", "col_label": "Project", "label": "Training Agents to Detect Early Cognitive Decline", "legend_label": "Training Agents to Detect Early Cognitive Decline", "order": "3", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "224438", "display_total": "$224,438", "subtitle": "$224,438 in", "subtitle2": "", "scaled_in": "224438", "scaled_out": "25000", "node_value": "224438", "full_title": "Training Artificial Intelligence Conversational Agents to Detect Early Cognitive Decline", "college": "Arts & Sciences", "department": "Psychological Sciences", "pi": "Heather Bailey"}, {"name": "AI-based Molecular Image Analysis", "col": "1", "col_label": "Project", "label": "AI-based Molecular Image Analysis", "legend_label": "AI-based Molecular Image Analysis", "order": "4", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "179249", "display_total": "$179,249", "subtitle": "$179,249 in", "subtitle2": "", "scaled_in": "179249", "scaled_out": "75000", "node_value": "179249", "full_title": "AI-based Molecular Image Analysis for Ultrafast Laser Experiments", "college": "Arts & Sciences", "department": "Physics", "pi": "Daniel Rolles"}, {"name": "Publications", "col": "2", "col_label": "Outcome type", "label": "Publications", "legend_label": "Publications", "order": "0", "color": "#AC9766", "text_color": "#835d09", "unit": "Publications", "true_total": "9", "display_total": "9 Publications", "subtitle": "9 publications", "subtitle2": "", "scaled_in": "45000", "scaled_out": "0", "node_value": "45000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Presentations", "col": "2", "col_label": "Outcome type", "label": "Presentations", "legend_label": "Presentations", "order": "1", "color": "#F4C55C", "text_color": "#000000", "unit": "Presentations", "true_total": "91", "display_total": "91 Presentations", "subtitle": "91 presentations", "subtitle2": "", "scaled_in": "455000", "scaled_out": "0", "node_value": "455000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Pending awards ($)", "col": "2", "col_label": "Outcome type", "label": "Pending awards ($)", "legend_label": "Pending awards ($)", "order": "2", "color": "#C8A2C8", "text_color": "#000000", "unit": "Dollars", "true_total": "2499979", "display_total": "$2,499,979", "subtitle": "13 submissions", "subtitle2": "$2,499,979 total", "scaled_in": "2499979", "scaled_out": "0", "node_value": "2499979", "full_title": "", "college": "", "department": "", "pi": ""}], "links": [{"source": "One K-State Fund", "target": "AI-based Molecular Image Analysis", "value": "61561", "trueValue": "61561", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI-based Molecular Image Analysis", "value": "117688", "trueValue": "117688", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "45453", "trueValue": "45453", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "54903", "trueValue": "54903", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Training Agents to Detect Early Cognitive Decline", "value": "117358", "trueValue": "117358", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Training Agents to Detect Early Cognitive Decline", "value": "107080", "trueValue": "107080", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "124628", "trueValue": "124628", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "123545", "trueValue": "123545", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "AI for Pollinators: Developing a Global Hub", "value": "151000", "trueValue": "151000", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI for Pollinators: Developing a Global Hub", "value": "96475", "trueValue": "96475", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "AI-based Molecular Image Analysis", "target": "Publications", "value": "20000", "trueValue": "4", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Presentations", "value": "55000", "trueValue": "11", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Publications", "value": "5000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Presentations", "value": "330000", "trueValue": "66", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Publications", "value": "5000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Presentations", "value": "20000", "trueValue": "4", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Presentations", "value": "15000", "trueValue": "3", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Pending awards ($)", "value": "1000000", "trueValue": "1000000", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Publications", "value": "15000", "trueValue": "3", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Presentations", "value": "35000", "trueValue": "7", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Pending awards ($)", "value": "1499979", "trueValue": "1499979", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}]}, {"key": "secured", "label": "Awards secured", "d": 5000, "nodes": [{"name": "One K-State Fund", "col": "0", "col_label": "Funding source", "label": "One K-State Fund", "legend_label": "One K-State Fund", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "500000", "display_total": "$500,000 across 5 projects", "subtitle": "$500,000", "subtitle2": "", "scaled_in": "0", "scaled_out": "500000", "node_value": "500000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Match Fund (OVPR)", "col": "0", "col_label": "Funding source", "label": "Match Fund (OVPR)", "legend_label": "Match Fund (OVPR)", "order": "1", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "499691", "display_total": "$499,691 across 5 projects", "subtitle": "$499,691", "subtitle2": "", "scaled_in": "0", "scaled_out": "499691", "node_value": "499691", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "AI for Pollinators: Developing a Global Hub", "col": "1", "col_label": "Project", "label": "AI for Pollinators: Developing a Global Hub", "legend_label": "AI for Pollinators: Developing a Global Hub", "order": "0", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "247475", "display_total": "$247,475", "subtitle": "$247,475 in", "subtitle2": "", "scaled_in": "247475", "scaled_out": "1819401", "node_value": "1819401", "full_title": "AI for Pollinators: Developing a Global Hub for Collaboration, Data Sharing, and Pollinator Research", "college": "Agriculture", "department": "Entomology", "pi": "Brian Spiesman"}, {"name": "AI-based Molecular Image Analysis", "col": "1", "col_label": "Project", "label": "AI-based Molecular Image Analysis", "legend_label": "AI-based Molecular Image Analysis", "order": "1", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "179249", "display_total": "$179,249", "subtitle": "$179,249 in", "subtitle2": "", "scaled_in": "179249", "scaled_out": "1075000", "node_value": "1075000", "full_title": "AI-based Molecular Image Analysis for Ultrafast Laser Experiments", "college": "Arts & Sciences", "department": "Physics", "pi": "Daniel Rolles"}, {"name": "Collaborating with AI: Writing Studies-Informed Applications", "col": "1", "col_label": "Project", "label": "Collaborating with AI: Writing Studies-Informed Applications", "legend_label": "Collaborating with AI: Writing Studies-Informed Applications", "order": "2", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "100356", "display_total": "$100,356", "subtitle": "$100,356 in", "subtitle2": "", "scaled_in": "100356", "scaled_out": "335000", "node_value": "335000", "full_title": "Collaborating with AI: A Project to Develop Writing Studies-Informed Applications of Generative AI for Teaching in Higher Education", "college": "Arts & Sciences", "department": "English", "pi": "Cydney Alexis"}, {"name": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "col": "1", "col_label": "Project", "label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "legend_label": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "order": "3", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "248173", "display_total": "$248,173", "subtitle": "$248,173 in", "subtitle2": "", "scaled_in": "248173", "scaled_out": "15000", "node_value": "248173", "full_title": "Mapping Seed-to-Plant Life Cycle to Predict Yields — Integrated Artificial Intelligence and Data Analytics Approach", "college": "Engineering", "department": "Biological and Agricultural Engineering", "pi": "Ajay Sharda"}, {"name": "Training Agents to Detect Early Cognitive Decline", "col": "1", "col_label": "Project", "label": "Training Agents to Detect Early Cognitive Decline", "legend_label": "Training Agents to Detect Early Cognitive Decline", "order": "4", "color": "#9F694F", "text_color": "#9F694F", "unit": "Dollars", "true_total": "224438", "display_total": "$224,438", "subtitle": "$224,438 in", "subtitle2": "", "scaled_in": "224438", "scaled_out": "25000", "node_value": "224438", "full_title": "Training Artificial Intelligence Conversational Agents to Detect Early Cognitive Decline", "college": "Arts & Sciences", "department": "Psychological Sciences", "pi": "Heather Bailey"}, {"name": "External awards ($)", "col": "2", "col_label": "Outcome type", "label": "External awards ($)", "legend_label": "Funding", "order": "0", "color": "#512888", "text_color": "#512888", "unit": "Dollars", "true_total": "2769401", "display_total": "$2,769,401", "subtitle": "3 awards", "subtitle2": "$2,769,401 total", "scaled_in": "2769401", "scaled_out": "0", "node_value": "2769401", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Publications", "col": "2", "col_label": "Outcome type", "label": "Publications", "legend_label": "Publications", "order": "1", "color": "#AC9766", "text_color": "#835d09", "unit": "Publications", "true_total": "9", "display_total": "9 Publications", "subtitle": "9 publications", "subtitle2": "", "scaled_in": "45000", "scaled_out": "0", "node_value": "45000", "full_title": "", "college": "", "department": "", "pi": ""}, {"name": "Presentations", "col": "2", "col_label": "Outcome type", "label": "Presentations", "legend_label": "Presentations", "order": "2", "color": "#F4C55C", "text_color": "#000000", "unit": "Presentations", "true_total": "91", "display_total": "91 Presentations", "subtitle": "91 presentations", "subtitle2": "", "scaled_in": "455000", "scaled_out": "0", "node_value": "455000", "full_title": "", "college": "", "department": "", "pi": ""}], "links": [{"source": "One K-State Fund", "target": "AI-based Molecular Image Analysis", "value": "61561", "trueValue": "61561", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI-based Molecular Image Analysis", "value": "117688", "trueValue": "117688", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "45453", "trueValue": "45453", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Collaborating with AI: Writing Studies-Informed Applications", "value": "54903", "trueValue": "54903", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Training Agents to Detect Early Cognitive Decline", "value": "117358", "trueValue": "117358", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Training Agents to Detect Early Cognitive Decline", "value": "107080", "trueValue": "107080", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "124628", "trueValue": "124628", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "value": "123545", "trueValue": "123545", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "One K-State Fund", "target": "AI for Pollinators: Developing a Global Hub", "value": "151000", "trueValue": "151000", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "Match Fund (OVPR)", "target": "AI for Pollinators: Developing a Global Hub", "value": "96475", "trueValue": "96475", "unit": "Dollars", "short_unit": "", "link_group": "funding"}, {"source": "AI-based Molecular Image Analysis", "target": "External awards ($)", "value": "1000000", "trueValue": "1000000", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Publications", "value": "20000", "trueValue": "4", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI-based Molecular Image Analysis", "target": "Presentations", "value": "55000", "trueValue": "11", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Publications", "value": "5000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Collaborating with AI: Writing Studies-Informed Applications", "target": "Presentations", "value": "330000", "trueValue": "66", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Publications", "value": "5000", "trueValue": "1", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "Training Agents to Detect Early Cognitive Decline", "target": "Presentations", "value": "20000", "trueValue": "4", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "Mapping Seed-to-Plant Life Cycle to Predict Yields", "target": "Presentations", "value": "15000", "trueValue": "3", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "External awards ($)", "value": "1769401", "trueValue": "1769401", "unit": "Dollars", "short_unit": "total", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Publications", "value": "15000", "trueValue": "3", "unit": "Publications", "short_unit": "publications", "link_group": "outcome"}, {"source": "AI for Pollinators: Developing a Global Hub", "target": "Presentations", "value": "35000", "trueValue": "7", "unit": "Presentations", "short_unit": "presentations", "link_group": "outcome"}]}];
const DEFAULT_VIEW = "all";
VIEWS.forEach(v => {
v.nodes.forEach(n => {
// `col` is the authored column. d3-sankey overwrites node.layer, so
// nothing here reads .layer after layout.
n.col = +n.col;
n.order = +n.order;
n.true_total = +n.true_total;
n.scaled_in = +n.scaled_in;
n.scaled_out = +n.scaled_out;
n.node_value = +n.node_value;
});
v.links.forEach(l => {
// value = dollar-equivalents, used for layout only. trueValue is the
// number in the link's own unit and is the only one a reader ever sees.
l.value = +l.value;
l.trueValue = +l.trueValue;
});
});
let viewIndex = Math.max(0, VIEWS.findIndex(v => v.key === DEFAULT_VIEW));
let DATA = VIEWS[viewIndex];
let D = DATA.d;
// ------------------------------------------------------------- constants
const W_MIN = 2; // thinnest drawn ribbon, px (widened, never clamped)
const NODE_W = 12; // bar width, px
const LANE_GUTTER = 10; // clear space between label lanes, px
const LABEL_GAP = 5; // label block bottom to bar top, px
const GAP_MIN = 12; // smallest vertical gap between stacked nodes, px
const HEAD_H = 24; // band reserved for the column heads, px
const BAR_HIT_W = 32; // tap width around a bar, px
const BAND_MIN = 12; // ribbons this wide or wider draw as filled bands
const TAP_MIN = 44; // minimum tap target height, px
const COLUMN_TITLES = ["Funding sources", "Projects", "Outcomes"];
const COLUMN_TITLES_SHORT = ["Funding", "Projects", "Outcomes"];
const DOLLAR_UNIT = "Dollars";
const usd = d3.format("$,.0f");
const int = d3.format(",d");
// Type sizes step down one notch on the narrowest phones.
function typeFor(W) {
const name = W < 375 ? 12.5 : 13;
return { name, value: name - 1, lh: Math.round(name * 1.25), head: 11 };
}
// --------------------------------------------------------- text measuring
// Labels are wrapped by MEASURED width, on the device's own font, so the
// lanes fit whatever system face the phone renders with.
const FONT = getComputedStyle(document.body).fontFamily;
const measureCtx = document.createElement("canvas").getContext("2d");
function textW(str, font) { measureCtx.font = font; return measureCtx.measureText(str).width; }
// Break at spaces, and after a hyphen only between parts of 3+ characters
// ("Studies-|Informed", "Seed-to-|Plant", never "K-|State" or "AI-|based").
// A short parenthetical such as "($)" is glued to the word before it so it
// never strands on a line of its own.
function tokenize(text) {
const glued = String(text).replace(/ (\(.{1,3}\))/g, " $1");
const tokens = [];
glued.split(/[ \t\n]+/).filter(Boolean).forEach((word, wi) => {
const parts = [];
word.replace(/-/g, "-\u0000").split("\u0000").filter(Boolean).forEach(p => {
const letters = p.replace(/-$/, "").length;
if (parts.length && (letters < 3 || parts[parts.length - 1].replace(/-$/, "").length < 3)) {
parts[parts.length - 1] += p;
} else parts.push(p);
});
parts.forEach((part, pi) => tokens.push({ t: part, sp: wi > 0 && pi === 0 }));
});
return tokens;
}
function greedy(tokens, maxW, font) {
const lines = [];
let cur = "";
tokens.forEach(tk => {
const cand = cur ? cur + (tk.sp ? " " : "") + tk.t : tk.t;
if (!cur || textW(cand, font) <= maxW) { cur = cand; return; }
lines.push(cur);
cur = tk.t;
});
if (cur) lines.push(cur);
return lines.length ? lines : [""];
}
// Wrap to maxW, then narrow the measure as far as it goes without adding a
// line, so the lines come out balanced ("AI for Pollinators: / Developing a
// Global Hub", not a lone "Hub" underneath).
function wrapPx(text, maxW, font, maxLines) {
const tokens = tokenize(text);
let lines = greedy(tokens, maxW, font);
if (lines.length > 1 && lines.length <= maxLines) {
let lo = d3.max(tokens, tk => textW(tk.t, font)), hi = maxW;
for (let i = 0; i < 10 && hi - lo > 1; i++) {
const mid = (lo + hi) / 2;
if (greedy(tokens, mid, font).length <= lines.length) hi = mid; else lo = mid;
}
lines = greedy(tokens, hi, font);
}
if (lines.length <= maxLines) return lines;
const kept = lines.slice(0, maxLines);
let last = kept[maxLines - 1];
while (last.length > 1 && textW(last + "…", font) > maxW) last = last.slice(0, -1).trimEnd();
kept[maxLines - 1] = last + "…";
return kept;
}
function capitalize(s) {
const t = String(s === undefined || s === null ? "" : s);
return t.charAt(0).toUpperCase() + t.slice(1);
}
// The one place a link's number becomes text. Always trueValue.
function fmtShort(trueValue, unit, shortUnit) {
const n = unit === DOLLAR_UNIT ? usd(trueValue) : int(trueValue);
const u = countUnit(trueValue, unit, shortUnit);
return u ? n + " " + u : n;
}
// A count of exactly one takes the singular: "1 publication", not
// "1 publications". The short units are stored as regular plurals, so
// "-ies" -> "-y" and a dropped final "s" cover them. Dollar units ("total")
// are never touched. Same rule as gripex_sankey_v25.html.
function countUnit(trueValue, unit, shortUnit) {
if (!shortUnit || unit === DOLLAR_UNIT || trueValue !== 1) return shortUnit;
return shortUnit.replace(/ies$/, "y").replace(/s$/, "");
}
function esc(s) {
return String(s === undefined || s === null ? "" : s)
.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
function copyList(list) { return list.map(d => Object.assign({}, d)); }
function valueLines(n) { return [n.subtitle, n.subtitle2].filter(Boolean); }
function linkKey(l) { return l.source.name + "→" + l.target.name; }
// ------------------------------------------------------------------ state
let graph = null; // the laid-out graph of the current render
let selected = null; // { type: "node" | "link", key }
let lastWidth = 0;
// ================================================================= render
function render() {
const host = document.getElementById("chart");
d3.select(host).selectAll("*").remove();
const W = Math.max(320, Math.round(host.clientWidth));
lastWidth = W;
const inset = parseFloat(getComputedStyle(document.documentElement)
.getPropertyValue("--page-inset")) || 16;
const T = typeFor(W);
const NAME_FONT = `600 ${T.name}px ${FONT}`;
const VALUE_FONT = `400 ${T.value}px ${FONT}`;
const HEAD_FONT = `700 ${T.head}px ${FONT}`;
// Chart height grows with width so the ribbons keep a phone-friendly
// aspect. The page scrolls; nothing is squeezed to fit the viewport.
const H = Math.round(Math.min(940, Math.max(620, W * 1.95)));
const innerH = H - HEAD_H;
// ---------------------------------------------------- d3-sankey layout
// d3-sankey builds the graph, assigns columns, x positions, node values
// and link breadths. The vertical axis is then re-packed below (as on
// desktop) so each label's height is reserved above its bar.
const sankey = d3.sankey()
.nodeId(d => d.name)
.nodeAlign(n => n.col)
.nodeWidth(NODE_W)
.nodePadding(0)
.nodeSort((a, b) => a.order - b.order)
.linkSort(null)
.extent([[inset, HEAD_H], [W - inset, H]]);
const g = sankey({ nodes: copyList(DATA.nodes), links: copyList(DATA.links) });
graph = g;
const cols = d3.group(g.nodes, n => n.col);
for (const members of cols.values()) members.sort((a, b) => a.order - b.order);
// --------------------------------------------------------- label lanes
// Each column's lane is as wide as its widest wrapped line (capped), and
// the centre lane takes what is left. Lanes never overlap, so labels in
// different columns can never collide.
const edgeCap = Math.round(W * 0.27);
function laneFit(nodes, cap) {
let w = 0;
nodes.forEach(n => {
wrapPx(n.label, cap, NAME_FONT, 3).forEach(l => { w = Math.max(w, textW(l, NAME_FONT)); });
valueLines(n).forEach(v => wrapPx(v, cap, VALUE_FONT, 2)
.forEach(l => { w = Math.max(w, textW(l, VALUE_FONT)); }));
});
return Math.ceil(Math.min(cap, w));
}
const leftW = laneFit(cols.get(0) || [], edgeCap);
const rightW = laneFit(cols.get(2) || [], edgeCap);
const centerW = W - 2 * inset - leftW - rightW - 2 * LANE_GUTTER;
const lanes = {
0: { x0: inset, w: leftW, anchor: "start", tx: inset },
1: { x0: inset + leftW + LANE_GUTTER, w: centerW, anchor: "middle" },
2: { x0: W - inset - rightW, w: rightW, anchor: "end", tx: W - inset },
};
lanes[1].tx = lanes[1].x0 + centerW / 2;
// Projects sit on the centre of their lane, which is not always the
// centre of the screen once the edge lanes size to their content.
(cols.get(1) || []).forEach(n => { n.x0 = lanes[1].tx - NODE_W / 2; n.x1 = n.x0 + NODE_W; });
g.nodes.forEach(n => {
const lane = lanes[n.col];
const nameLines = wrapPx(n.label, lane.w, NAME_FONT, n.col === 1 ? 4 : 3);
const vals = [];
valueLines(n).forEach(v => wrapPx(v, lane.w, VALUE_FONT, 2).forEach(l => vals.push(l)));
n._lines = nameLines.map(t => ({ t, cls: "lbl lbl-name" }))
.concat(vals.map(t => ({ t, cls: "lbl lbl-value" })));
n._labelH = n._lines.length * T.lh + LABEL_GAP;
});
// ------------------------------------------------------------- scale
// One px-per-dollar for every ribbon, chosen so the busiest column (bars
// + label blocks + minimum gaps) fills the chart. Lifting thin ribbons to
// W_MIN adds height, so re-fit until it stops overflowing.
const faceIn = n => d3.sum(n.targetLinks, l => l.width);
const faceOut = n => d3.sum(n.sourceLinks, l => l.width);
const faceH = n => Math.max(faceIn(n), faceOut(n));
const overhead = members => d3.sum(members, n => n._labelH) + (members.length + 1) * GAP_MIN;
let ky = Infinity;
for (const members of cols.values()) {
ky = Math.min(ky, (innerH - overhead(members)) / d3.sum(members, n => n.value));
}
for (let pass = 0; pass < 8; pass++) {
g.links.forEach(l => { l.width = Math.max(W_MIN, l.value * ky); });
let shrink = 1;
for (const members of cols.values()) {
const avail = innerH - overhead(members);
const used = d3.sum(members, faceH);
if (used > avail + 0.5) shrink = Math.min(shrink, avail / used);
}
if (shrink === 1) break;
ky *= shrink;
}
// ----------------------------------------------------------- repack
// Equal whitespace per column, n + 1 gaps, as on desktop -- but each node
// now occupies its label block plus its bar.
for (const members of cols.values()) {
const used = d3.sum(members, n => n._labelH + faceH(n));
const gap = (innerH - used) / (members.length + 1);
let y = HEAD_H + gap;
members.forEach(n => {
n._labelTop = y;
n.y0 = y + n._labelH;
n.y1 = n.y0 + faceH(n);
y = n.y1 + gap;
});
}
// Stack ribbon ends in the order of the node at their other end, so
// ribbons leave and arrive without crossing at the bars.
g.nodes.forEach(n => {
n.sourceLinks.sort((a, b) => a.target.y0 - b.target.y0);
n.targetLinks.sort((a, b) => a.source.y0 - b.source.y0);
});
sankey.update(g);
// --------------------------------------------------------------- svg
const svg = d3.select(host).append("svg")
.attr("width", W).attr("height", H)
.attr("viewBox", [0, 0, W, H])
.attr("aria-label", "Sankey diagram of GRIPex funding sources, projects and outcomes")
.on("click", () => clearSelection());
const defs = svg.append("defs");
g.links.forEach((l, i) => {
l.gradientId = `grad-${DATA.key}-${i}`;
const grad = defs.append("linearGradient")
.attr("id", l.gradientId).attr("gradientUnits", "userSpaceOnUse")
.attr("x1", l.source.x1).attr("x2", l.target.x0);
grad.append("stop").attr("offset", "0%").attr("stop-color", l.source.color);
grad.append("stop").attr("offset", "100%").attr("stop-color", l.target.color);
});
// Column heads, falling back to the short title if the left head would
// run into the centre one.
const headW = (t) => textW(t.toUpperCase(), HEAD_FONT) + t.length * T.head * 0.09;
const titles = COLUMN_TITLES.slice();
if (inset + headW(titles[0]) + 12 > lanes[1].tx - headW(titles[1]) / 2) titles[0] = COLUMN_TITLES_SHORT[0];
svg.append("g").selectAll("text")
.data([0, 1, 2]).join("text")
.attr("class", "col-head")
.attr("x", c => lanes[c].tx).attr("y", 12)
.attr("text-anchor", c => lanes[c].anchor)
.attr("font-size", T.head)
.text(c => titles[c]);
// ------------------------------------------------------------ ribbons
// The band between two horizontal-tangent curves, built from d3-sankey's
// own link.y0 / link.y1 / link.width and the same midpoint control points
// as d3.sankeyLinkHorizontal.
function ribbonPath(l) {
const x0 = l.source.x1, x1 = l.target.x0, xm = (x0 + x1) / 2, h = l.width / 2;
const a0 = l.y0 - h, b0 = l.y0 + h, a1 = l.y1 - h, b1 = l.y1 + h;
return `M${x0},${a0}C${xm},${a0} ${xm},${a1} ${x1},${a1}`
+ `L${x1},${b1}C${xm},${b1} ${xm},${b0} ${x0},${b0}Z`;
}
const centreLine = d3.sankeyLinkHorizontal();
const onLink = (event, d) => { event.stopPropagation(); select({ type: "link", key: linkKey(d) }, event); };
// A wide invisible stroke under each thin ribbon so a 2 px ribbon is
// still tappable. Visible ribbons sit above, so where they overlap the
// one you can see is the one you get.
svg.append("g").attr("class", "link-hits")
.selectAll("path").data(g.links.filter(l => l.width < 16)).join("path")
.attr("class", "link-hit")
.attr("d", centreLine)
.attr("stroke-width", 16)
.on("click", onLink);
const isBand = l => l.width >= BAND_MIN;
svg.append("g").attr("class", "links")
.selectAll("path").data(g.links).join("path")
.attr("class", l => isBand(l) ? "link band" : "link line")
.attr("d", l => isBand(l) ? ribbonPath(l) : centreLine(l))
.attr("fill", l => isBand(l) ? `url(#${l.gradientId})` : null)
.attr("stroke", l => isBand(l) ? null : `url(#${l.gradientId})`)
.attr("stroke-width", l => isBand(l) ? null : l.width)
.on("click", onLink);
// -------------------------------------------------------------- nodes
// A bar's left edge is the sum of ribbons arriving, its right edge the
// sum leaving; projects draw as a wedge when the two differ.
function nodePath(n) {
const hIn = faceIn(n), hOut = faceOut(n);
const left = hIn > 0 ? hIn : hOut;
const right = hOut > 0 ? hOut : hIn;
return `M${n.x0},${n.y0} L${n.x1},${n.y0} L${n.x1},${n.y0 + right} L${n.x0},${n.y0 + left} Z`;
}
const nodeG = svg.append("g").attr("class", "nodes")
.selectAll("g").data(g.nodes).join("g")
.attr("class", "node")
.attr("tabindex", 0)
.attr("role", "button")
.attr("aria-label", n => `${n.label}. ${valueLines(n).join(", ")}. Show details`)
.on("click", (event, n) => { event.stopPropagation(); select({ type: "node", key: n.name }, event); })
.on("keydown", (event, n) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
select({ type: "node", key: n.name }, event);
}
});
// Tap area: the whole label block across its lane, plus a column around
// the bar, at least TAP_MIN tall in all.
const tapH = n => Math.max(TAP_MIN, n.y1 - n._labelTop + 4);
nodeG.append("rect").attr("class", "node-hit")
.attr("x", n => lanes[n.col].x0).attr("y", n => n._labelTop - 2)
.attr("width", n => lanes[n.col].w).attr("height", n => n._labelH + 2);
nodeG.append("rect").attr("class", "node-hit")
.attr("x", n => (n.x0 + n.x1) / 2 - BAR_HIT_W / 2).attr("y", n => n._labelTop - 2)
.attr("width", BAR_HIT_W).attr("height", tapH);
nodeG.append("rect").attr("class", "node-ring")
.attr("x", n => Math.min(lanes[n.col].x0, n.x0) - 3).attr("y", n => n._labelTop - 4)
.attr("width", n => Math.max(lanes[n.col].w, NODE_W) + 6)
.attr("height", n => n.y1 - n._labelTop + 8).attr("rx", 4);
nodeG.append("path")
.attr("class", "node-shape")
.attr("d", nodePath)
.attr("fill", n => n.color);
// Labels: name lines then value lines, stacked on top of the bar and
// anchored to the lane (left, centre, right).
nodeG.each(function (n) {
const cell = d3.select(this);
const lane = lanes[n.col];
const x = n.col === 1 ? (n.x0 + n.x1) / 2 : lane.tx;
n._lines.forEach((line, i) => {
const isName = line.cls.includes("lbl-name");
cell.append("text")
.attr("class", line.cls)
.attr("x", x)
.attr("y", n._labelTop + i * T.lh + T.lh * 0.78)
.attr("text-anchor", lane.anchor)
.attr("font-size", isName ? T.name : T.value)
.attr("fill", isName ? null : (n.col === 2 ? n.text_color : "#666666"))
.text(line.t);
});
});
// --------------------------------------------------------- footnote
const kyMin = d3.min(g.links, l => l.width / l.value);
d3.select("#scale-note").text(
`Ribbon width is dollars throughout: 1 px ≈ ${usd(1 / kyMin)}. `
+ `Count ribbons are scaled for visibility: One count is drawn as ${usd(D)}.`);
applySelection();
}
// ================================================================= legend
function renderLegend() {
const items = DATA.nodes.filter(n => n.col === 2).sort((a, b) => a.order - b.order);
d3.select("#legend").selectAll("span.legend-item").data(items, d => d.name).join(
enter => {
const s = enter.append("span").attr("class", "legend-item");
s.append("i").attr("aria-hidden", "true");
s.append("span");
return s;
})
.each(function (d) {
const s = d3.select(this);
s.select("i").style("background", d.color);
s.select("span").text(d.legend_label);
});
}
// ============================================================== selection
function findSelected() {
if (!selected || !graph) return null;
if (selected.type === "node") return graph.nodes.find(n => n.name === selected.key) || null;
return graph.links.find(l => linkKey(l) === selected.key) || null;
}
function applySelection() {
const svg = d3.select("#chart svg");
const item = findSelected();
if (!item) {
svg.classed("focus", false);
svg.selectAll(".link, .node").classed("on", false).classed("picked", false);
return;
}
const onNodes = new Set(), onLinks = new Set();
if (selected.type === "node") {
onNodes.add(item);
item.sourceLinks.concat(item.targetLinks).forEach(l => {
onLinks.add(l); onNodes.add(l.source); onNodes.add(l.target);
});
} else {
onLinks.add(item); onNodes.add(item.source); onNodes.add(item.target);
}
svg.classed("focus", true);
svg.selectAll(".link").classed("on", d => onLinks.has(d));
svg.selectAll(".node").classed("on", d => onNodes.has(d))
.classed("picked", d => selected.type === "node" && d === item);
}
let returnFocus = null;
function select(sel, event) {
selected = sel;
applySelection();
const item = findSelected();
if (!item) return;
const viaKeyboard = !!event && event.type === "keydown";
returnFocus = viaKeyboard ? event.currentTarget : null;
// Where the reader's attention is: the tap point, or for a keypress the
// bottom of the focused node.
const focusY = !event ? null
: viaKeyboard ? event.currentTarget.getBoundingClientRect().bottom
: event.clientY;
openSheet(selected.type === "node" ? nodeSheet(item) : linkSheet(item), viaKeyboard, focusY);
}
function clearSelection() {
if (!selected) return;
selected = null;
applySelection();
closeSheet();
}
// ================================================================== sheet
const sheet = document.getElementById("sheet");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
function openSheet({ title, html }, focusClose, focusY) {
document.getElementById("sheet-title").textContent = title;
const body = document.getElementById("sheet-body");
body.innerHTML = html;
body.scrollTop = 0;
sheet.classList.add("open");
sheet.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => {
// Room to scroll the bottom of the chart clear of the sheet, then lift
// the page if the sheet would cover what was just tapped.
const sheetH = sheet.offsetHeight;
document.body.style.paddingBottom = sheetH + "px";
const clearLine = window.innerHeight - sheetH - 24;
if (focusY !== null && focusY !== undefined && focusY > clearLine) {
window.scrollBy({ top: focusY - clearLine + 48, behavior: reduceMotion.matches ? "auto" : "smooth" });
}
});
if (focusClose) document.getElementById("sheet-close").focus();
}
function closeSheet() {
sheet.classList.remove("open");
sheet.setAttribute("aria-hidden", "true");
document.body.style.paddingBottom = "";
if (returnFocus && document.body.contains(returnFocus)) returnFocus.focus();
returnFocus = null;
}
document.getElementById("sheet-close").addEventListener("click", clearSelection);
document.addEventListener("keydown", e => { if (e.key === "Escape") clearSelection(); });
function row(k, v, opts = {}) {
const dot = opts.color ? `<i style="background:${esc(opts.color)}" aria-hidden="true"></i>` : "";
return `<div class="sheet-row${opts.total ? " total" : ""}"><span class="k">${dot}${esc(k)}</span><span class="v">${esc(v)}</span></div>`;
}
const section = t => `<div class="sheet-section">${esc(t)}</div>`;
// Mirrors the desktop tooltips, and on a phone also lists each flow, since
// tapping a thin ribbon is harder than hovering one.
function nodeSheet(n) {
let html = "";
if (n.col === 0) {
html += row("Dispensed", usd(n.true_total));
html += row("Projects funded", int(n.sourceLinks.length));
html += section("By project");
n.sourceLinks.forEach(l => { html += row(l.target.label, fmtShort(l.trueValue, l.unit, l.short_unit)); });
} else if (n.col === 1) {
html += section("Funding in");
n.targetLinks.forEach(l => { html += row(l.source.label, usd(l.trueValue)); });
html += row("Total in", usd(n.true_total), { total: true });
html += section("Outcomes");
n.sourceLinks.forEach(l => {
html += row(l.target.label, fmtShort(l.trueValue, l.unit, l.short_unit), { color: l.target.color });
});
html += `<div class="sheet-meta"><strong>${esc(n.full_title)}</strong>`
+ `<br>${esc(n.college)} · ${esc(n.department)}`
+ `<br>PI: ${esc(n.pi)}</div>`;
} else {
html += row("Total", n.display_total);
html += row("Projects contributing", int(n.targetLinks.length));
html += section("By project");
n.targetLinks.forEach(l => { html += row(l.source.label, fmtShort(l.trueValue, l.unit, l.short_unit)); });
html += `<div class="sheet-meta">Unit: ${esc(capitalize(n.unit))}.</div>`;
}
return { title: n.label, html };
}
function linkSheet(l) {
let html = row(l.link_group === "funding" ? "Funding" : "Outcome",
fmtShort(l.trueValue, l.unit, l.short_unit));
html += l.unit === DOLLAR_UNIT
? `<div class="sheet-meta">Dollars, drawn at the same scale as every other dollar ribbon.</div>`
: `<div class="sheet-meta">A count, drawn at ${usd(D)} per unit so it is visible beside the dollars. Its width is not comparable to a dollar ribbon.</div>`;
return { title: `${l.source.label} → ${l.target.label}`, html };
}
// =========================================================== view switcher
const viewButtons = d3.select("#views").selectAll("button").data(VIEWS).join("button")
.attr("type", "button")
.text(v => v.label)
.on("click", (event, v) => {
const i = VIEWS.indexOf(v);
if (i === viewIndex) return;
viewIndex = i;
applyView();
});
function applyView() {
DATA = VIEWS[viewIndex];
D = DATA.d;
viewButtons.attr("aria-pressed", (v, i) => String(i === viewIndex));
selected = null;
closeSheet();
render();
renderLegend();
}
applyView();
// Mobile browsers fire resize as the address bar shows and hides; only a
// change of WIDTH (rotation, split screen) needs a new layout.
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (Math.round(document.getElementById("chart").clientWidth) !== lastWidth) render();
}, 200);
});
</script>
</body>
</html>
Pop out this diagram and learn more about our GRIPex: AI in the Disciplines awards.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>K-State core facility funding and research impact flow diagram, FY24–FY25</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
/* Two knobs for the whole document, both set from 03_render_sankey.py and
both read back by the JS so nothing has to be kept in sync by hand.
--page-inset the page margin. Header, footer and empty-state pad by it;
the SVG legend reads it for its left edge and the grand
total's right edge, so they line up down the right margin.
--type-step one step of type size. EVERY font-size below is written as
its base size plus this, so all text scales from a single
number and 0 restores the original typography. The label
geometry that depends on text height re-tunes off it too. */
:root { --page-inset: 28px; --type-step: 2px; }
html, body {
width: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: #FFFFFF;
color: #000000;
}
#container { display: flex; flex-direction: column; width: 100%; height: 100%; }
header {
flex-shrink: 0; padding: 18px var(--page-inset) 10px;
border-bottom: 1px solid #D1D1D1;
}
h1 { font-size: calc(20px + var(--type-step)); font-weight: 650; color: #512888; letter-spacing: -0.01em; }
.subtitle { margin-top: 4px; font-size: calc(14px + var(--type-step)); color: #2a2a2a; max-width: 90ch; }
.scale-note {
font-size: calc(12.5px + var(--type-step)); font-weight: 600; color: #512888;
background: #F3EEF9; border-left: 3px solid #512888;
padding: 6px 10px; border-radius: 2px; display: inline-block;
}
.header-row {
margin-top: 8px; display: flex; align-items: center;
justify-content: space-between; gap: 20px; flex-wrap: wrap;
}
/* margin-left:auto as well as space-between: the auto margin still pushes
the control right if the row ever wraps to one item. */
.controls { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.controls label {
font-size: calc(11px + var(--type-step)); font-weight: 700; color: #512888;
letter-spacing: 0.09em; text-transform: uppercase;
}
.controls select {
font: inherit; font-size: calc(13px + var(--type-step)); color: #1a1a1a;
padding: 5px 9px; border: 1px solid #A7A7A7; border-radius: 3px;
background: #FFFFFF; cursor: pointer;
}
.controls select:hover { border-color: #512888; }
.controls select:focus-visible { outline: 2px solid #512888; outline-offset: 1px; }
#filter-summary { font-size: calc(12px + var(--type-step)); color: #666; }
.empty-state { padding: 40px var(--page-inset); font-size: calc(14px + var(--type-step)); color: #666; }
#chart { flex: 1; width: 100%; position: relative; overflow: hidden; min-height: 420px; }
#chart svg { display: block; }
footer {
flex-shrink: 0; padding: 9px var(--page-inset); border-top: 1px solid #D1D1D1;
font-size: calc(11.5px + var(--type-step)); color: #666;
display: flex; gap: 22px; flex-wrap: wrap; justify-content: space-between;
}
/* Right-aligned on --page-inset, the same margin as the filter control and
the grand total. margin-left:auto keeps it right if the footer wraps. */
#scale-readout { margin-left: auto; text-align: right; }
#scale-readout .scale-unit { font-weight: 700; }
/* geometricPrecision, not crispEdges: crisp snapping would round a 0.5
stroke back up to a solid 1 px line and undo the thinning. */
.band-rule { stroke-width: 0.5; shape-rendering: geometricPrecision; }
.band-label {
pointer-events: none; font-size: calc(11px + var(--type-step)); font-weight: 700;
letter-spacing: 0.08em;
paint-order: stroke; stroke: #FFFFFF; stroke-width: 2px;
stroke-linecap: round; stroke-linejoin: round;
}
.link { fill: none; stroke-opacity: 0.42; transition: stroke-opacity 0.12s; }
.link:hover { stroke-opacity: 0.72; }
.link.dimmed { stroke-opacity: 0.08; }
.node-shape { cursor: ns-resize; stroke: #FFFFFF; stroke-width: 1; shape-rendering: geometricPrecision; }
.node-shape:hover { stroke: #512888; stroke-width: 1.5; }
.node-label, .node-value, .col-head, .legend-text {
pointer-events: none;
paint-order: stroke;
stroke: #FFFFFF;
stroke-width: 3px;
stroke-linecap: round;
stroke-linejoin: round;
}
.node-label { font-size: calc(11.5px + var(--type-step)); font-weight: 600; fill: #1a1a1a; }
/* 9.5px, not 10: every node carries a value line now, and the smallest
nodes leave barely enough vertical room for the pair. */
.node-value { font-size: calc(9.5px + var(--type-step)); fill: #666; }
.col-head {
font-size: calc(11px + var(--type-step)); font-weight: 700; fill: #512888;
letter-spacing: 0.09em; text-transform: uppercase;
}
.legend-text { font-size: calc(11.5px + var(--type-step)); fill: #333; }
.legend-total {
font-size: calc(15px + var(--type-step)); font-weight: 700;
paint-order: stroke; stroke: #FFFFFF; stroke-width: 3px;
stroke-linecap: round; stroke-linejoin: round;
}
/* fixed + display:none, not absolute + opacity:0. An absolutely
positioned tooltip with no left/top yet still sits in the document
flow, below a #container that is already the full viewport height —
which overflowed the body and put a scrollbar on the iframe until the
first hover assigned coordinates and pulled it out of flow. Fixed
positioning and display:none keep it out of the document's scroll
extent at all times, so the only thing that can scroll the page is
#chart hitting its min-height. */
#tooltip {
position: fixed; max-width: 340px; padding: 10px 12px;
background: rgba(20, 10, 32, 0.94); color: #fff; border-radius: 5px;
pointer-events: none; display: none;
font-size: calc(12px + var(--type-step)); line-height: 1.5; z-index: 10;
box-shadow: 0 4px 14px rgba(0,0,0,0.28);
}
#tooltip .tt-title { font-weight: 700; margin-bottom: 5px; }
#tooltip .tt-row { display: flex; justify-content: space-between; gap: 14px; }
#tooltip .tt-key { color: #C8A2C8; }
#tooltip .tt-meta { margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.2); color: #ddd; font-size: calc(11px + var(--type-step)); }
</style>
</head>
<body>
<div id="container">
<header>
<h1>K-State core facility funding and research impact flow diagram, FY24–FY25</h1>
<div class="subtitle">See how funding core research facilities translates to research impact across K-State.</div>
<div class="header-row">
<div class="scale-note">Ribbon width = dollars left of projects, research groups right of projects.</div>
<div class="controls">
<span id="filter-summary"></span>
<label for="fy-filter">Fiscal year</label>
<select id="fy-filter">
<option value="FY24">FY24</option>
<option value="FY25">FY25</option>
<option value="All" selected>All</option>
</select>
</div>
</div>
</header>
<div id="chart"></div>
<footer>
<span>Hover a ribbon or node for detail</span>
<span id="scale-readout"></span>
</footer>
</div>
<div id="tooltip"></div>
<script>
// ---------------------------------------------------------------- palette
const PALETTE_CATEGORICAL = ["#512888", "#CEA152", "#9F694F", "#AC9766", "#F4C55C", "#939193", "#835d09", "#E4D89E", "#FFF1C9", "#C8A2C8"];
const PALETTE_CATEGORICAL_20 = ["#512888", "#CEA152", "#9F694F", "#AC9766", "#F4C55C", "#939193", "#835d09", "#E4D89E", "#FFF1C9", "#C8A2C8", "#925cd8", "#e6c68f", "#d0a089", "#d4c39b", "#f9db9b", "#c6b2c6", "#f5c866", "#f1e9c3", "#fff6dd", "#e2c2e2"];
const PALETTE_SEQUENTIAL_ORDINAL = ["#9F694F", "#AC9766", "#CEA152", "#F4C55C", "#E4D89E", "#FFF1C9"];
const PALETTE_SEQUENTIAL_ORDINAL_10 = ["#9f694f", "#a6805b", "#ac9766", "#bd9c5c", "#cea152", "#e1b357", "#f4c55c", "#eccf7d", "#e4d89e", "#fff1c9"];
const PALETTE_SEQUENTIAL_NUMERIC_1O = ["#D3C0ED", "#C8A2C8", "#BB95C1", "#A37CB4", "#956EAD", "#8861A6", "#774F9D", "#6C4497", "#5D358F", "#512888"];
const PALETTE_SEQUENTIAL_NUMERIC_2O = ["#C8A2C8", "#C29CC5", "#BB95C1", "#B690BE", "#AD87B9", "#A983B7", "#A37CB4", "#9C75B0", "#956EAD", "#8F68AA", "#8861A6", "#7E57A1", "#774F9D", "#724A9A", "#6C4497", "#643C93", "#5D358F", "#562E8B", "#532B89", "#512888"];
const PALETTE_PRIMARY = {purple: "#512888", lilac: "#C8A2C8", white: "#FFFFFF", black: "#000000", light_gray: "#D1D1D1", mid_gray: "#A7A7A7", sand: "#E7DED0"};
const PALETTE_ACCENT = {cream_1: "#E7DED0", cream_2: "#B9AB97", gray_soft: "#939193", dark_gold: "#835d09"};
const LAYER_COLORS = {0: "#512888", 1: "#9F694F", 2: "#CEA152"};
const LAYER2_TEXT_COLOR = "#835d09";
const FY_RULE_COLOR = "#000000";
const FY_CAPTION_COLOR = "#000000";
// ------------------------------------------------------------------- data
const DATA = {"nodes": [{"name": "One K-State fund", "layer": "0", "label": "One K-State fund", "fiscal_year": "", "sponsor_unit": "", "order": "0", "dollars_in": "0.00", "dollars_out": "956748.20", "groups_in": "0", "groups_out": "0"}, {"name": "Matching fund (various)", "layer": "0", "label": "Matching fund (various)", "fiscal_year": "", "sponsor_unit": "", "order": "1", "dollars_in": "0.00", "dollars_out": "956748.20", "groups_in": "0", "groups_out": "0"}, {"name": "Ackert Hall; Fume hood replacement", "layer": "1", "label": "Fume hood replacement", "fiscal_year": "FY24", "sponsor_unit": "Arts & Sciences - Arts & Sciences - Biology", "order": "0", "dollars_in": "340000.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "12"}, {"name": "Cardwell Hall Various labs; installation of emergency eyewash stations", "layer": "1", "label": "Installation of emergency eyewash stations", "fiscal_year": "FY24", "sponsor_unit": "Arts & Sciences - Arts & Sciences - Physics", "order": "1", "dollars_in": "60000.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "10"}, {"name": "Seaton Hall; Northwest HVAC", "layer": "1", "label": "Northwest HVAC", "fiscal_year": "FY24", "sponsor_unit": "Engineering - Engineering - Biological and Agricultural Engineering", "order": "2", "dollars_in": "465498.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "9"}, {"name": "Justin Hall Rm 130; Lab renovation", "layer": "1", "label": "Lab renovation, Rm 130", "fiscal_year": "FY24", "sponsor_unit": "Health & Human Sciences - Health & Human Sciences - Kinesiology", "order": "3", "dollars_in": "65498.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "5"}, {"name": "Repair of non-functioning Perchloric Acid Lab Hoods with wash system", "layer": "1", "label": "Repair Perchloric Acid Lab Hoods", "fiscal_year": "FY25", "sponsor_unit": "K-State Research and Extension; Dep of Agronomy", "order": "4", "dollars_in": "86916.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "255"}, {"name": "Extending AI Resources for KSU Researchers", "layer": "1", "label": "AI Resources for KSU Researchers", "fiscal_year": "FY25", "sponsor_unit": "College of Engineering", "order": "5", "dollars_in": "66227.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "150"}, {"name": "Acquisition of the Agilent’s 4150 Tape Station automated electrophoretic system", "layer": "1", "label": "Agilent electrophoresis 4150 TapeStation platform", "fiscal_year": "FY25", "sponsor_unit": "College of Agriculture and Department of Plant Pathology", "order": "6", "dollars_in": "33912.80", "dollars_out": "0.00", "groups_in": "0", "groups_out": "55"}, {"name": "Acquisition of a plate reader with multiple detection modes and integrated ACU/RI", "layer": "1", "label": "Multi-mode integrated ACU/RI plate reader", "fiscal_year": "FY25", "sponsor_unit": "Office of Research Development and College of A&S", "order": "7", "dollars_in": "72000.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "20"}, {"name": "Installation of Backup Power for IMEC", "layer": "1", "label": "Installation of Backup Power for IMEC", "fiscal_year": "FY25", "sponsor_unit": "Entomology", "order": "8", "dollars_in": "178895.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "14"}, {"name": "Reactor Control Room Upgrade", "layer": "1", "label": "Reactor Control Room Upgrade", "fiscal_year": "FY25", "sponsor_unit": "College of Engineering", "order": "9", "dollars_in": "94794.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "13"}, {"name": "Upgrading the irrigation system at KRV-Topeka", "layer": "1", "label": "Upgrading the irrigation system", "fiscal_year": "FY25", "sponsor_unit": "Agronomy", "order": "10", "dollars_in": "21419.80", "dollars_out": "0.00", "groups_in": "0", "groups_out": "12"}, {"name": "Acquisition of a Scanning Probe Microscope", "layer": "1", "label": "Scanning Probe Microscope", "fiscal_year": "FY25", "sponsor_unit": "Chemical Engineer, Civil Engineering, Industrial and Manufacturing Systems Engineering, and the College of Engineering", "order": "11", "dollars_in": "195020.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "10"}, {"name": "Large equipment purchase: Cytek Aurora Flow Cytometer", "layer": "1", "label": "Cytek Aurora Flow Cytometer", "fiscal_year": "FY25", "sponsor_unit": "Department of Diagnostic Medicine/Pathobiology", "order": "12", "dollars_in": "176000.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "9"}, {"name": "Expansion and Renovation of the K-State Fly Food Kitchen", "layer": "1", "label": "Fly Food Kitchen Expansion/Renovation", "fiscal_year": "FY25", "sponsor_unit": "Division of Biology", "order": "13", "dollars_in": "32500.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "5"}, {"name": "Purchase of a Replacement Nitrogen Generator for Analytical Pharmacology", "layer": "1", "label": "Replacement Nitrogen Generator", "fiscal_year": "FY25", "sponsor_unit": "Anatomy & Physiology- College of Vet Med", "order": "14", "dollars_in": "24815.80", "dollars_out": "0.00", "groups_in": "0", "groups_out": "5"}, {"name": "Ackert Hall", "layer": "2", "label": "Ackert Hall", "fiscal_year": "", "sponsor_unit": "", "order": "0", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "12", "groups_out": "0"}, {"name": "Cardwell Hall", "layer": "2", "label": "Cardwell Hall", "fiscal_year": "", "sponsor_unit": "", "order": "1", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "10", "groups_out": "0"}, {"name": "Seaton Hall", "layer": "2", "label": "Seaton Hall", "fiscal_year": "", "sponsor_unit": "", "order": "2", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "9", "groups_out": "0"}, {"name": "Justin Hall", "layer": "2", "label": "Justin Hall", "fiscal_year": "", "sponsor_unit": "", "order": "3", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "5", "groups_out": "0"}, {"name": "KSRE Soil Testing Laboratory", "layer": "2", "label": "KSRE Soil Testing Laboratory", "fiscal_year": "", "sponsor_unit": "", "order": "4", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "255", "groups_out": "0"}, {"name": "Beocat", "layer": "2", "label": "Beocat", "fiscal_year": "", "sponsor_unit": "", "order": "5", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "150", "groups_out": "0"}, {"name": "K-State Integrated Genomics Facility", "layer": "2", "label": "K-State Integrated Genomics Facility", "fiscal_year": "", "sponsor_unit": "", "order": "6", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "55", "groups_out": "0"}, {"name": "Protein and Biopolymer Analysis Core Lab", "layer": "2", "label": "Protein and Biopolymer Analysis Core Lab", "fiscal_year": "", "sponsor_unit": "", "order": "7", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "20", "groups_out": "0"}, {"name": "Integrated Molecular Entomology Core", "layer": "2", "label": "Integrated Molecular Entomology Core", "fiscal_year": "", "sponsor_unit": "", "order": "8", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "14", "groups_out": "0"}, {"name": "Triga Mark 2 Nuclear Reactor Facility", "layer": "2", "label": "Triga Mark 2 Nuclear Reactor Facility", "fiscal_year": "", "sponsor_unit": "", "order": "9", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "13", "groups_out": "0"}, {"name": "Kansas River Valley Experimental Field - Topeka", "layer": "2", "label": "Kansas River Valley Experimental Field - Topeka", "fiscal_year": "", "sponsor_unit": "", "order": "10", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "12", "groups_out": "0"}, {"name": "Materials Characterization Laboratory", "layer": "2", "label": "Materials Characterization Laboratory", "fiscal_year": "", "sponsor_unit": "", "order": "11", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "10", "groups_out": "0"}, {"name": "Flow Cytometry Laboratory", "layer": "2", "label": "Flow Cytometry Laboratory", "fiscal_year": "", "sponsor_unit": "", "order": "12", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "9", "groups_out": "0"}, {"name": "K-State Fly Food Kitchen", "layer": "2", "label": "K-State Fly Food Kitchen", "fiscal_year": "", "sponsor_unit": "", "order": "13", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "5", "groups_out": "0"}, {"name": "Analytical Pharmacology Core Lab", "layer": "2", "label": "Analytical Pharmacology Core Lab", "fiscal_year": "", "sponsor_unit": "", "order": "14", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "5", "groups_out": "0"}], "links": [{"source": "One K-State fund", "target": "Ackert Hall; Fume hood replacement", "value": "170000.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Ackert Hall; Fume hood replacement", "value": "170000.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Cardwell Hall Various labs; installation of emergency eyewash stations", "value": "30000.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Cardwell Hall Various labs; installation of emergency eyewash stations", "value": "30000.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Seaton Hall; Northwest HVAC", "value": "232749.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Seaton Hall; Northwest HVAC", "value": "232749.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Justin Hall Rm 130; Lab renovation", "value": "32749.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Justin Hall Rm 130; Lab renovation", "value": "32749.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Repair of non-functioning Perchloric Acid Lab Hoods with wash system", "value": "43458.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Repair of non-functioning Perchloric Acid Lab Hoods with wash system", "value": "43458.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Extending AI Resources for KSU Researchers", "value": "33113.50", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Extending AI Resources for KSU Researchers", "value": "33113.50", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Acquisition of the Agilent’s 4150 Tape Station automated electrophoretic system", "value": "16956.40", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Acquisition of the Agilent’s 4150 Tape Station automated electrophoretic system", "value": "16956.40", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Acquisition of a plate reader with multiple detection modes and integrated ACU/RI", "value": "36000.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Acquisition of a plate reader with multiple detection modes and integrated ACU/RI", "value": "36000.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Installation of Backup Power for IMEC", "value": "89447.50", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Installation of Backup Power for IMEC", "value": "89447.50", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Reactor Control Room Upgrade", "value": "47397.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Reactor Control Room Upgrade", "value": "47397.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Upgrading the irrigation system at KRV-Topeka", "value": "10709.90", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Upgrading the irrigation system at KRV-Topeka", "value": "10709.90", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Acquisition of a Scanning Probe Microscope", "value": "97510.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Acquisition of a Scanning Probe Microscope", "value": "97510.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Large equipment purchase: Cytek Aurora Flow Cytometer", "value": "88000.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Large equipment purchase: Cytek Aurora Flow Cytometer", "value": "88000.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Expansion and Renovation of the K-State Fly Food Kitchen", "value": "16250.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Expansion and Renovation of the K-State Fly Food Kitchen", "value": "16250.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Purchase of a Replacement Nitrogen Generator for Analytical Pharmacology", "value": "12407.90", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Purchase of a Replacement Nitrogen Generator for Analytical Pharmacology", "value": "12407.90", "unit": "dollars", "link_group": "funding"}, {"source": "Ackert Hall; Fume hood replacement", "target": "Ackert Hall", "value": "12", "unit": "groups", "link_group": "impact"}, {"source": "Cardwell Hall Various labs; installation of emergency eyewash stations", "target": "Cardwell Hall", "value": "10", "unit": "groups", "link_group": "impact"}, {"source": "Seaton Hall; Northwest HVAC", "target": "Seaton Hall", "value": "9", "unit": "groups", "link_group": "impact"}, {"source": "Justin Hall Rm 130; Lab renovation", "target": "Justin Hall", "value": "5", "unit": "groups", "link_group": "impact"}, {"source": "Repair of non-functioning Perchloric Acid Lab Hoods with wash system", "target": "KSRE Soil Testing Laboratory", "value": "255", "unit": "groups", "link_group": "impact"}, {"source": "Extending AI Resources for KSU Researchers", "target": "Beocat", "value": "150", "unit": "groups", "link_group": "impact"}, {"source": "Acquisition of the Agilent’s 4150 Tape Station automated electrophoretic system", "target": "K-State Integrated Genomics Facility", "value": "55", "unit": "groups", "link_group": "impact"}, {"source": "Acquisition of a plate reader with multiple detection modes and integrated ACU/RI", "target": "Protein and Biopolymer Analysis Core Lab", "value": "20", "unit": "groups", "link_group": "impact"}, {"source": "Installation of Backup Power for IMEC", "target": "Integrated Molecular Entomology Core", "value": "14", "unit": "groups", "link_group": "impact"}, {"source": "Reactor Control Room Upgrade", "target": "Triga Mark 2 Nuclear Reactor Facility", "value": "13", "unit": "groups", "link_group": "impact"}, {"source": "Upgrading the irrigation system at KRV-Topeka", "target": "Kansas River Valley Experimental Field - Topeka", "value": "12", "unit": "groups", "link_group": "impact"}, {"source": "Acquisition of a Scanning Probe Microscope", "target": "Materials Characterization Laboratory", "value": "10", "unit": "groups", "link_group": "impact"}, {"source": "Large equipment purchase: Cytek Aurora Flow Cytometer", "target": "Flow Cytometry Laboratory", "value": "9", "unit": "groups", "link_group": "impact"}, {"source": "Expansion and Renovation of the K-State Fly Food Kitchen", "target": "K-State Fly Food Kitchen", "value": "5", "unit": "groups", "link_group": "impact"}, {"source": "Purchase of a Replacement Nitrogen Generator for Analytical Pharmacology", "target": "Analytical Pharmacology Core Lab", "value": "5", "unit": "groups", "link_group": "impact"}]};
DATA.nodes.forEach(n => {
n.layer = +n.layer;
// d3-sankey overwrites node.layer with the column index *within the graph it
// was passed to*. In the right pass that makes projects layer 0 and
// facilities layer 1, which would paint the impact ribbons in the funding
// half's colours. Keep the true column under a name the library won't touch.
n.col = n.layer;
n.order = +n.order;
n.dollars_in = +n.dollars_in;
n.dollars_out = +n.dollars_out;
n.groups_in = +n.groups_in;
n.groups_out = +n.groups_out;
});
DATA.links.forEach(l => { l.value = +l.value; });
// Node column width. Drives the column x-positions, both sankey extents and
// the FY band, so it is set from 03_render_sankey.py rather than edited here.
// A project's connector is NODE_W wide — the run over which its left edge
// (dollar-scaled) resolves into its right edge (group-scaled).
const NODE_W = 48;
// Column titles, indexed by layer. Used by both the column heads and the
// legend key — one string so the two can't drift apart. Layer 2 holds core
// facility nodes, but the column is titled for what its ribbons measure.
const COLUMN_TITLES = ["Funding sources", "Projects", "Research Groups"];
// One type step in px, matching --type-step in the stylesheet. Bigger text
// needs more vertical room between labels, so the three layout constants that
// depend on text height are derived from it rather than fixed. At TYPE_STEP 0
// all three collapse to the values they had before the scale existed.
const TYPE_STEP = 2;
// Fiscal-year filter. Module scope, not render scope: render() re-runs on
// every resize and the selection has to survive that.
let fyFilter = "All";
const usd = d3.format("$,.2f");
const usd0 = d3.format("$,.0f");
const int = d3.format(",d");
const tooltip = d3.select("#tooltip");
function fmtValue(v, unit) {
return unit === "dollars" ? usd(v) : int(v) + (v === 1 ? " research group" : " research groups");
}
function truncate(text, maxChars) {
return text.length <= maxChars ? text : text.slice(0, Math.max(1, maxChars - 1)).trimEnd() + "…";
}
// A project belongs to two graphs at once, so each pass gets its own copies.
function copyNodes(list) { return list.map(d => Object.assign({}, d)); }
function copyLinks(list) { return list.map(d => Object.assign({}, d)); }
function render() {
d3.select("#chart").selectAll("*").remove();
const box = document.getElementById("chart").getBoundingClientRect();
const W = Math.max(760, box.width);
const H = Math.max(460, box.height);
// Label gutters scale with the viewport; text truncates to fit what's left.
const mLeft = Math.round(Math.min(210, Math.max(120, W * 0.14)));
const mRight = Math.round(Math.min(300, Math.max(150, W * 0.20)));
const mTop = 34;
const legendH = 30; // one row: swatch key + grand total
const mBottom = 14 + legendH;
const innerW = W - mLeft - mRight;
const innerH = H - mTop - mBottom;
const fundChars = Math.floor(mLeft / 6.4);
// The facility name is layer 2's sublabel, set at 9.5px rather than 11.5px,
// so it gets a wider character budget than the fund column's labels.
const facSubChars = Math.floor(mRight / 5.2);
// Project labels sit in the funding-ribbon zone, right-aligned against the
// project column, drawn last with a white halo so ribbons read behind them.
// Cap sized to the longest hand-edited project_short (49 chars at ~6 px/char).
// The gutter overlays the funding ribbon zone rather than reserving space
// from it — labels sit on top with a white halo — so widening costs nothing
// in layout, only in how far left the text may reach.
// Column x positions. Both passes place the project column at projX.
const fundX = 0;
const facX = innerW - NODE_W;
const projX = Math.round(fundX + NODE_W + (facX - fundX - NODE_W) * 0.52);
// How far left of the project column the FY divider runs. Its own span —
// the project labels moved to the right side in v22, so this no longer
// backs a label gutter.
const bandLead = Math.round(Math.min(300, Math.max(120, innerW * 0.30)));
// Project labels sit to the RIGHT of the project column, overlaying the
// impact ribbon zone with a white halo. Their budget is that zone's width,
// less a margin so they stop short of the facility column. Declared here,
// after the column x positions it reads — const has no hoisting.
const projLabelSpace = Math.max(120, facX - (projX + NODE_W) - 20);
const projChars = Math.floor(Math.min(300, projLabelSpace) / 6.0);
const svg = d3.select("#chart").append("svg")
.attr("width", W).attr("height", H)
.attr("viewBox", [0, 0, W, H])
.attr("font-family", "inherit");
const defs = svg.append("defs");
const root = svg.append("g").attr("transform", `translate(${mLeft},${mTop})`);
// ------------------------------------------ split by unit / layer / year
// The fiscal-year filter is applied here and nowhere else. Both layout
// passes, the two ky factors, the FY divider, the legend and the grand total
// are all derived from these arrays, so they follow without further work.
const allProjects = DATA.nodes.filter(n => n.layer === 1);
const projects = allProjects
.filter(n => fyFilter === "All" || n.fiscal_year === fyFilter)
.sort((a, b) => a.order - b.order);
const kept = new Set(projects.map(n => n.name));
const dollarLinks = DATA.links.filter(l => l.unit === "dollars" && kept.has(l.target));
const groupLinks = DATA.links.filter(l => l.unit === "groups" && kept.has(l.source));
// Facilities follow the surviving impact links, not the layer alone.
const reached = new Set(groupLinks.map(l => l.target));
const facilities = DATA.nodes.filter(n => n.layer === 2 && reached.has(n.name));
// Both funds always survive a filter, but their stored dollars_out is the
// whole-dataset figure. Left as-is it would name money that is no longer on
// the canvas, so recompute each fund from the links still visible.
const funds = DATA.nodes.filter(n => n.layer === 0).map(n => Object.assign({}, n, {
dollars_out: d3.sum(dollarLinks.filter(l => l.source === n.name), l => l.value)
}));
d3.select("#filter-summary").text(
fyFilter === "All" ? "" : `${projects.length} of ${allProjects.length} projects`);
// d3.sankey() throws on an empty node set. Both years are populated so this
// cannot fire today, but a blank page is a poor failure mode for a filter.
if (!projects.length) {
d3.select("#chart").selectAll("*").remove();
d3.select("#scale-readout").text("");
d3.select("#chart").append("div").attr("class", "empty-state")
.text(`No projects in ${fyFilter}.`);
return;
}
// Padding sets how much vertical room adjacent labels get: centre-to-centre
// spacing is (h_i + h_i+1)/2 + nodePad, and the smallest faces contribute
// almost nothing to that. Since every node now shows a name AND a value line
// (~20 px of text), the padding carries the separation. Widened from
// innerH/46 capped at 16, which left the tightest pairs ~3 px short.
const nodePad = Math.max(8, Math.min(18 + TYPE_STEP, innerH / (36 - TYPE_STEP)));
// A node face is drawn as the exact sum of the ribbons touching it, so these
// three are the single source of truth for every height on the canvas.
const faceIn = n => d3.sum(n.targetLinks, l => l.width);
const faceOut = n => d3.sum(n.sourceLinks, l => l.width);
const faceH = n => Math.max(faceIn(n), faceOut(n));
const faceBottom = n => n.y0 + faceH(n);
// Minimum drawn thickness for a project face, in px.
//
// d3-sankey sizes a face at value * ky, which leaves the smallest projects
// under 3 px — hairlines that don't visibly meet the ribbons on either side.
// Fattening only the drawn stroke would make that worse, not better: the
// face is drawn from the node's own extent, so a widened ribbon would
// overhang the node it lands on. What is widened instead is the LINK, after
// which d3 restacks the endpoints from the new widths — so the face and its
// ribbons stay equal by construction, at any size.
const MIN_FACE = 6 + 1.5 * TYPE_STEP;
function liftFaces(graph, side) {
graph.nodes.forEach(n => {
const links = side === "in" ? n.targetLinks : n.sourceLinks;
if (!links.length) return;
const raw = d3.sum(links, l => l.width);
if (raw <= 0 || raw >= MIN_FACE) return;
const f = MIN_FACE / raw;
links.forEach(l => { l.width *= f; });
});
}
// Widening pushes the stack past the canvas, and the project column already
// fills it exactly. So lay out, measure the overflow, and lay out again on a
// shorter extent. Two corrective passes land within a fraction of a pixel.
function fitPass(gen, nodeList, linkList, x0, x1, side) {
let h = innerH, graph;
for (let pass = 0; pass < 3; pass++) {
gen.extent([[x0, 0], [x1, h]]);
graph = gen({ nodes: copyNodes(nodeList), links: copyLinks(linkList) });
liftFaces(graph, side);
gen.update(graph);
const over = d3.max(graph.nodes, faceBottom) - innerH;
if (over <= 0.5) break;
h -= over;
}
return graph;
}
// Left pass — dollars. Its own ky, derived from the dollar column sums.
// Projects are the targets here, so their faces are the incoming side.
const sankeyLeft = d3.sankey()
.nodeId(d => d.name)
.nodeWidth(NODE_W)
.nodePadding(nodePad)
.nodeSort((a, b) => a.order - b.order);
const gLeft = fitPass(sankeyLeft, funds.concat(projects), dollarLinks,
fundX, projX + NODE_W, "in");
// Right pass — research groups. Separate ky. Same vertical extent, same
// project x, so the two halves align on the shared column. Projects are the
// sources here, so their faces are the outgoing side.
const sankeyRight = d3.sankey()
.nodeId(d => d.name)
.nodeWidth(NODE_W)
.nodePadding(nodePad)
.nodeSort((a, b) => a.order - b.order);
const gRight = fitPass(sankeyRight, projects.concat(facilities), groupLinks,
projX, facX + NODE_W, "out");
const leftByName = new Map(gLeft.nodes.map(d => [d.name, d]));
const rightByName = new Map(gRight.nodes.map(d => [d.name, d]));
// The two pixels-per-unit factors, reported in the legend. Taken as the MIN
// ratio: a lifted link sits above the true ky, an unlifted one is exactly on
// it, so the minimum is the honest scale the legend should quote.
const kyDollars = d3.min(gLeft.links, l => l.width / l.value);
const kyGroups = d3.min(gRight.links, l => l.width / l.value);
// ------------------------------------------------------------ FY bands
// Scope: the project column only — never full height across the chart.
// The divider runs LEFT of the project column, across the funding fan, and
// is anchored to the FUNDING (left) faces.
//
// The two stacks put the FY boundary in completely different places: FY24 is
// 48.7% of the dollars but only 6.2% of the groups, so the group-side FY25
// boundary sits near the top of the chart while the funding-side one sits
// near the middle. No single horizontal rule is true on both sides. v23 tried
// it on the right against the group faces and it was reverted — the funding
// stack is the one this rule reads. It must not cross the node column, where
// it would slice through the steeply slanted connectors.
const bandX0 = projX - bandLead - 10;
const bandX1 = projX - 2;
const bandGroups = d3.groups(projects, d => d.fiscal_year)
.sort((a, b) => d3.ascending(a[0], b[0]));
// The rule drops a few px below the band top; the caption's baseline sits
// just above it, so the caption rests on the rule and its descenders (the
// p and j of "projects") come down to meet it.
const FY_RULE_DROP = 3;
const FY_CAPTION_LIFT = 5;
const ruleY = d => d.y0 + FY_RULE_DROP;
const captionY = d => d.y0 + FY_RULE_DROP - FY_CAPTION_LIFT;
const bands = bandGroups.map(([fy, members]) => {
const faces = members.map(m => leftByName.get(m.name));
return {
fy: fy,
y0: d3.min(faces, f => f.y0) - nodePad * 0.5,
y1: d3.max(faces, faceBottom) + nodePad * 0.5,
n: members.length
};
});
// No filled band. A caption and a hairline rule across the project column
// mark each fiscal year; the caption's white halo interrupts the rule, so
// the two read as one section divider. Anything filled here muddies the
// ribbons passing behind it.
// The group itself is appended after the ribbons — see "FY divider" below.
// Drawn here it would sit under them and the funding fan would paint over
// the rule and the caption's halo alike.
// ------------------------------------------------------------ gradients
const allLinks = gLeft.links.concat(gRight.links);
allLinks.forEach((l, i) => {
l.gradientId = `grad-${i}`;
const grad = defs.append("linearGradient")
.attr("id", l.gradientId)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", l.source.x1).attr("x2", l.target.x0);
grad.append("stop").attr("offset", "0%").attr("stop-color", LAYER_COLORS[l.source.col]);
grad.append("stop").attr("offset", "100%").attr("stop-color", LAYER_COLORS[l.target.col]);
});
// ---------------------------------------------------------------- links
const linkPath = d3.sankeyLinkHorizontal();
const linkSel = root.append("g").attr("class", "links")
.selectAll("path").data(allLinks).join("path")
.attr("class", "link")
.attr("d", linkPath)
.attr("stroke", d => `url(#${d.gradientId})`)
.attr("stroke-width", d => d.width)
.on("mousemove", (event, d) => showTooltip(event, linkTooltip(d)))
.on("mouseleave", hideTooltip);
// ---------------------------------------------------------------- nodes
// Funds and facilities are plain rects. A project is a connector whose left
// edge is dollar-scaled and right edge group-scaled — the heights differ and
// the trapezoid says so out loud.
const shapeG = root.append("g").attr("class", "nodes");
const fundSel = shapeG.append("g").selectAll("rect")
.data(funds.map(n => leftByName.get(n.name))).join("rect")
.attr("class", "node-shape")
.attr("x", d => d.x0).attr("y", d => d.y0)
.attr("width", d => d.x1 - d.x0)
.attr("height", d => Math.max(1, faceOut(d)))
.attr("fill", LAYER_COLORS[0])
.on("mousemove", (event, d) => showTooltip(event, nodeTooltip(d, 0)))
.on("mouseleave", hideTooltip);
const facSel = shapeG.append("g").selectAll("rect")
.data(facilities.map(n => rightByName.get(n.name))).join("rect")
.attr("class", "node-shape")
.attr("x", d => d.x0).attr("y", d => d.y0)
.attr("width", d => d.x1 - d.x0)
.attr("height", d => Math.max(1, faceIn(d)))
.attr("fill", LAYER_COLORS[2])
.on("mousemove", (event, d) => showTooltip(event, nodeTooltip(d, 2)))
.on("mouseleave", hideTooltip);
const projPairs = projects.map(n => ({
name: n.name,
src: n,
L: leftByName.get(n.name),
R: rightByName.get(n.name)
}));
function connectorPath(p) {
const x0 = p.L.x0, x1 = p.R.x1;
return `M${x0},${p.L.y0} L${x1},${p.R.y0} L${x1},${p.R.y0 + faceOut(p.R)} L${x0},${p.L.y0 + faceIn(p.L)} Z`;
}
const projSel = shapeG.append("g").selectAll("path")
.data(projPairs).join("path")
.attr("class", "node-shape")
.attr("d", connectorPath)
.attr("fill", LAYER_COLORS[1])
.on("mousemove", (event, d) => showTooltip(event, nodeTooltip(d.src, 1)))
.on("mouseleave", hideTooltip);
// ---------------------------------------------------------- FY divider
// Appended after the ribbons and nodes so it reads over the funding fan it
// crosses. It stays below the labels, so a long project label wins any
// collision with the caption — the data beats the section header.
const bandG = root.append("g").attr("class", "bands");
bandG.selectAll("line").data(bands).join("line")
.attr("class", "band-rule")
.attr("x1", bandX0).attr("x2", bandX1)
.attr("y1", ruleY).attr("y2", ruleY)
.attr("stroke", FY_RULE_COLOR);
bandG.selectAll("text").data(bands).join("text")
.attr("class", "band-label")
.attr("x", bandX0)
.attr("y", captionY)
.attr("fill", FY_CAPTION_COLOR)
.text(d => `${d.fy} · ${d.n} projects`);
// --------------------------------------------------------------- labels
// Every node carries its value line, whatever its height — no suppression on
// small nodes. The smallest faces are only 4–5 px tall, so the pair is
// tightly leaded (10 px between baselines) to fit the room they leave. The
// node padding above is sized to match; see nodePad.
const NAME_DY = -(4 + 0.6 * TYPE_STEP);
const VALUE_DY = 6 + 0.6 * TYPE_STEP;
const labelG = root.append("g").attr("class", "labels");
function nameY(d) { return labelY(d) + NAME_DY; }
function valueY(d) { return labelY(d) + VALUE_DY; }
function addLabel(sel, xFn, anchor, nameFn, valueFn) {
const g = labelG.append("g");
g.selectAll("text.node-label").data(sel).join("text")
.attr("class", "node-label")
.attr("x", xFn).attr("y", nameY)
.attr("text-anchor", anchor)
.text(nameFn);
g.selectAll("text.node-value").data(sel).join("text")
.attr("class", "node-value")
.attr("x", xFn).attr("y", valueY)
.attr("text-anchor", anchor)
.text(valueFn);
return g;
}
const fundData = funds.map(n => leftByName.get(n.name));
addLabel(fundData, d => d.x0 - 8, "end",
d => truncate(d.label, fundChars),
d => usd0(d.dollars_out));
// Layer 2 runs label and sublabel the other way round: the group count is
// the headline and the facility name sits under it. The column measures
// research groups, so the count is what the eye should land on first.
const facData = facilities.map(n => rightByName.get(n.name));
addLabel(facData, d => d.x1 + 8, "start",
d => `${int(d.groups_in)} group${d.groups_in === 1 ? "" : "s"}`,
d => truncate(d.label, facSubChars));
addLabel(projPairs, d => d.R.x1 + 8, "start",
d => truncate(d.src.label, projChars),
d => `${usd0(d.src.dollars_in)} · ${int(d.src.groups_out)} groups`);
// --------------------------------------------------------- column heads
const heads = [
{ x: fundX + NODE_W / 2, text: COLUMN_TITLES[0], anchor: "middle" },
{ x: projX + NODE_W / 2, text: COLUMN_TITLES[1], anchor: "middle" },
{ x: facX + NODE_W / 2, text: COLUMN_TITLES[2], anchor: "middle" }
];
root.append("g").selectAll("text").data(heads).join("text")
.attr("class", "col-head")
.attr("x", d => d.x).attr("y", -14)
.attr("text-anchor", d => d.anchor)
.text(d => d.text);
// --------------------------------------------------------------- legend
// One text element per line with tspans, so nothing can overlap regardless
// of how wide the rendered glyphs turn out to be.
// The legend spans the full page width, not the plot area: its left edge and
// the grand total's right edge are both LEGEND_INSET from the page edge, so
// the row reads as one symmetric band under the chart. Anchoring it to
// mLeft instead would start it wherever the fund labels happen to end.
// Read from the --page-inset custom property so the SVG legend, the header
// padding and the filter control all share one number.
const LEGEND_INSET = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--page-inset")
) || 28;
const legend = svg.append("g")
.attr("transform", `translate(${LEGEND_INSET},${mTop + innerH + 26})`);
const swatchRow = legend.append("g");
const legendItems = [];
COLUMN_TITLES.forEach((name, lyr) => {
const g = swatchRow.append("g");
g.append("rect").attr("x", 0).attr("y", -9).attr("width", 11).attr("height", 11)
.attr("rx", 2).attr("fill", LAYER_COLORS[lyr]);
g.append("text").attr("class", "legend-text").attr("x", 17).attr("y", 0).text(name);
legendItems.push(g);
});
// One key for the fiscal-year divider. The bands carry no fill, so there is
// nothing per-year to swatch — the in-chart captions name themselves.
const fyKey = swatchRow.append("g");
fyKey.append("line").attr("class", "band-rule")
.attr("x1", 0).attr("x2", 22).attr("y1", -4).attr("y2", -4)
.attr("stroke", FY_RULE_COLOR);
fyKey.append("text").attr("class", "legend-text").attr("x", 28).attr("y", 0)
.text("Fiscal year divider");
legendItems.push(fyKey);
// Lay the items out by their MEASURED width with one constant gap, so the
// white space between every pair is identical whatever the label lengths or
// type step. Fixed x slots (the old 132 px pitch and 430 px FY key) left the
// gaps varying with each label's length.
const LEGEND_GAP = 16 + TYPE_STEP;
let legendCursor = 0;
legendItems.forEach(g => {
g.attr("transform", `translate(${legendCursor},0)`);
legendCursor += g.node().getBBox().width + LEGEND_GAP;
});
// Scale readout lives in the footer, right-aligned opposite the hover hint.
// Set here because both ky factors are recomputed on every resize and filter.
// The groups scale flips to px per group when a group is wider than a pixel:
// FY24 alone draws ~25 px per group, which "1 px ≈ N groups" rounded to
// "0.0 groups".
const groupsScale = 1 / kyGroups >= 1
? `1 px ≈ ${(1 / kyGroups).toFixed(1)} groups`
: `1 group ≈ ${kyGroups.toFixed(1)} px`;
// LAYER2_TEXT_COLOR, not LAYER_COLORS[2]: the column's gold is 2.37:1 on
// white, under the 4.5:1 AA floor for normal-size text.
d3.select("#scale-readout").html(
`Ribbon width = <span class="scale-unit" style="color:${LAYER_COLORS[0]}">dollars</span>`
+ ` left of projects (1 px ≈ ${usd0(1 / kyDollars)}), `
+ `<span class="scale-unit" style="color:${LAYER2_TEXT_COLOR}">research groups</span>`
+ ` right of projects (${groupsScale})`);
// Grand total, far right, on the legend's own plane. Worded as engagements
// on purpose: metadata.right_half_count_semantics records that a group using
// two facilities contributes to two links, so this is not a headcount of
// distinct groups. Summed from the data, never hardcoded.
const groupTotal = d3.sum(facilities, d => d.groups_in);
const totalX = W - 2 * LEGEND_INSET; // local coords: right edge lands at W - LEGEND_INSET
const totalLine = legend.append("text")
.attr("class", "legend-total")
.attr("x", totalX).attr("y", 2)
.attr("text-anchor", "end")
.attr("fill", PALETTE_PRIMARY.purple)
.text(`${int(groupTotal)} research groups impacted`);
// It shares a row with the swatch key. Measure the real glyph widths rather
// than guessing from character counts, and drop the total if it would
// collide at a narrow window.
const GAP = 18;
const swatchBox = swatchRow.node().getBBox();
if (totalX - totalLine.node().getComputedTextLength() < swatchBox.x + swatchBox.width + GAP) {
totalLine.attr("display", "none");
}
// ----------------------------------------------------------------- drag
function refresh() {
sankeyLeft.update(gLeft);
sankeyRight.update(gRight);
linkSel.attr("d", linkPath).attr("stroke-width", d => d.width);
allLinks.forEach(l => {
defs.select(`#${l.gradientId}`).attr("x1", l.source.x1).attr("x2", l.target.x0);
});
fundSel.attr("y", d => d.y0).attr("height", d => Math.max(1, faceOut(d)));
facSel.attr("y", d => d.y0).attr("height", d => Math.max(1, faceIn(d)));
projSel.attr("d", connectorPath);
labelG.selectAll("text.node-label").attr("y", nameY);
labelG.selectAll("text.node-value").attr("y", valueY);
const moved = recomputeBands();
bandG.selectAll("line").data(moved).join("line")
.attr("y1", ruleY).attr("y2", ruleY);
bandG.selectAll("text").data(moved).join("text")
.attr("y", captionY);
}
// Centred on the DRAWN face, which after liftFaces is no longer d3's y0..y1.
// A project labels against its RIGHT face — the connector is a wedge, so the
// two edges sit at very different heights and the label has to track the one
// it is drawn beside.
function labelY(d) {
if (d.R) return d.R.y0 + faceOut(d.R) / 2;
return d.y0 + faceH(d) / 2;
}
function recomputeBands() {
return bandGroups.map(([fy, members]) => {
const faces = members.map(m => leftByName.get(m.name));
return {
fy: fy,
y0: d3.min(faces, f => f.y0) - nodePad * 0.5,
y1: d3.max(faces, f => f.y1) + nodePad * 0.5,
n: members.length
};
});
}
function moveNode(node, y) {
const h = faceH(node);
node.y0 = Math.max(0, Math.min(innerH - h, y));
node.y1 = node.y0 + h;
}
const dragSingle = d3.drag()
.subject(d => ({ y: d.y0 }))
.on("start", function () { this.parentNode.appendChild(this); })
.on("drag", function (event, d) { moveNode(d, event.y); refresh(); });
// A project exists in both passes; drag moves both copies by the same delta
// so its two faces stay joined. The delta is clamped against both faces at
// once — clamping them separately would shear the connector at the extremes.
const dragProject = d3.drag()
.subject(d => ({ y: d.L.y0 }))
.on("start", function () { this.parentNode.appendChild(this); })
.on("drag", function (event, d) {
const lo = Math.max(-d.L.y0, -d.R.y0);
const hi = Math.min(innerH - faceBottom(d.L), innerH - faceBottom(d.R));
const dy = Math.max(lo, Math.min(hi, event.y - d.L.y0));
d.L.y0 += dy; d.L.y1 += dy;
d.R.y0 += dy; d.R.y1 += dy;
refresh();
});
fundSel.call(dragSingle);
facSel.call(dragSingle);
projSel.call(dragProject);
// -------------------------------------------------------------- tooltips
function linkTooltip(d) {
const half = d.unit === "dollars" ? "Funding flow" : "Impact flow";
return `<div class="tt-title">${esc(d.source.label)} → ${esc(d.target.label)}</div>`
+ row(half, fmtValue(d.value, d.unit))
+ `<div class="tt-meta">Width on this half = ${d.unit}.</div>`;
}
function nodeTooltip(n, layer) {
let body = `<div class="tt-title">${esc(n.label)}</div>`;
if (layer === 0) {
body += row("Funding out", usd(n.dollars_out));
body += row("Projects funded", int(dollarLinks.filter(l => l.source === n.name).length));
} else if (layer === 1) {
body += row("Dollars in", usd(n.dollars_in));
body += row("Research groups out", int(n.groups_out));
body += row("Fiscal year", n.fiscal_year);
body += `<div class="tt-meta"><strong>${esc(n.name)}</strong>`;
if (n.sponsor_unit) body += `<br>Sponsor: ${esc(n.sponsor_unit)}`;
body += `<br></div>`;
} else {
body += row("Research groups in", int(n.groups_in));
body += `<div class="tt-meta">Number of research groups impacted or expected to be impacted in 3 year period.</div>`;
}
return body;
}
function row(k, v) {
return `<div class="tt-row"><span class="tt-key">${k}</span><span>${v}</span></div>`;
}
}
function esc(s) {
return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
function showTooltip(event, html) {
const pad = 14;
// clientX/clientY and getBoundingClientRect are viewport coordinates, which
// is what a fixed-position tooltip is placed in. pageX/pageY are document
// coordinates and mis-clamp inside a scrolled iframe.
tooltip.html(html).style("display", "block");
const bb = tooltip.node().getBoundingClientRect();
let x = event.clientX + pad, y = event.clientY - pad;
if (x + bb.width > window.innerWidth) x = event.clientX - bb.width - pad;
if (y + bb.height > window.innerHeight) y = window.innerHeight - bb.height - 6;
tooltip.style("left", x + "px").style("top", Math.max(6, y) + "px");
}
function hideTooltip() { tooltip.style("display", "none"); }
d3.select("#fy-filter").on("change", function () {
fyFilter = this.value;
render();
});
render();
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(render, 250);
});
console.log(`dual-unit sankey: ${DATA.nodes.length} nodes, ${DATA.links.length} links`);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>K-State core facility funding and research impact flow diagram, FY24–FY25</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<style>
/* Mobile companion to core_facilities_sankey_v31.html. Same data, same
palette, same d3-sankey layout engine, same dual-unit reading (dollars
left of projects, research groups right of projects, each half on its
own scale). What changes for a phone, following gripex_sankey_mobile_v2:
- The chart keeps the left-to-right flow but is drawn tall, and the PAGE
scrolls. Its height comes from the content (15 projects each need a
label), not from the viewport.
- Every label sits on top of its own bar, inside one of three vertical
lanes (left, centre, right). Lanes never overlap, and within a lane the
packing reserves each label's height, so no two labels can collide.
- Each core facility is drawn level with the project that feeds it, so
the research-group ribbons run flat and the two label stacks line up.
- The fiscal-year divider becomes a section header in the project lane,
with its own reserved space.
- Hover tooltips become a tap-to-open detail card that opens directly
under the tapped bar or ribbon, and the tapped node or ribbon is
brought forward while the rest dims.
- Embedded in an iframe (the CMS), the page sizes its frame to its own
content height, growing AND shrinking as views change: directly when
the host page is on the same origin, otherwise via a postMessage
{type:"kstate-funnel:height", height} the host page can listen for
(the same message the open-call funnel sends, so one listener serves
both).
- The fiscal-year <select> becomes a segmented control that sticks to
the top while the chart scrolls.
- Node dragging is dropped: on a touch screen it fights page scroll. */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--page-inset: 16px;
--purple: #512888;
--wash: #F3EEF9;
--rule: #D1D1D1;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { font-family: var(--font); background: #FFFFFF; color: #000000; }
header { padding: 18px var(--page-inset) 14px; }
h1 {
font-size: 21px; line-height: 1.22; font-weight: 650; color: var(--purple);
letter-spacing: -0.01em; text-wrap: balance;
}
.subtitle { margin-top: 6px; font-size: 15px; line-height: 1.4; color: #2a2a2a; }
.scale-note {
margin-top: 10px; font-size: 13.5px; line-height: 1.4; font-weight: 600;
color: var(--purple); background: var(--wash);
border-left: 3px solid var(--purple); padding: 7px 10px; border-radius: 2px;
}
/* ------------------------------------------------- fiscal-year switcher */
.views-bar {
position: sticky; top: 0; z-index: 5; background: #FFFFFF;
padding: 8px var(--page-inset); border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
}
.views {
display: flex; gap: 2px;
padding: 2px; border: 1px solid var(--purple); border-radius: 999px;
background: var(--wash);
}
/* Two lines per button: the year, and how many projects it holds. The
count replaces the desktop's "4 of 15 projects" summary. */
.views button {
flex: 1 1 0; min-width: 0;
min-height: 44px; padding: 4px 8px; border: 0; border-radius: 999px;
background: transparent; color: var(--purple);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
font-family: var(--font); cursor: pointer;
-webkit-tap-highlight-color: transparent; transition: background 0.12s, color 0.12s;
}
.views .v-main { font-size: 14px; line-height: 1.1; font-weight: 700; }
.views .v-sub { font-size: 11px; line-height: 1.1; font-weight: 500; }
.views button[aria-pressed="true"] { background: var(--purple); color: #FFFFFF; }
.views button:focus-visible { outline: 2px solid var(--purple); outline-offset: 2px; }
/* --------------------------------------------------------------- chart */
#chart { padding-top: 10px; }
#chart svg {
display: block; font-family: inherit; overflow: visible;
-webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none;
}
.empty-state { padding: 32px var(--page-inset); font-size: 15px; color: #595959; }
/* Wide ribbons are filled bands, not thick strokes: on a narrow screen the
curves are steep, and a wide stroke on a steep curve folds over itself
and bulges past the bar it lands on. A band's two edges cannot. Thin
ribbons stay strokes, which keep their thickness through the bend. */
.link { cursor: pointer; transition: fill-opacity 0.15s, stroke-opacity 0.15s; }
.link.band { stroke: none; fill-opacity: 0.42; }
.link.line { fill: none; stroke-opacity: 0.42; }
.link-hit { fill: none; stroke: #000; stroke-opacity: 0; pointer-events: stroke; cursor: pointer; }
.node { cursor: pointer; transition: opacity 0.15s; outline: none; }
.node-shape { stroke: #FFFFFF; stroke-width: 1; }
.node-hit { fill: transparent; }
.node-ring { fill: none; stroke: none; }
.node:focus-visible .node-ring { stroke: var(--purple); stroke-width: 2; stroke-dasharray: 4 3; }
/* Selection: the chosen node or ribbon and everything it touches stay at
full strength; the rest steps back. */
svg.focus .link.band { fill-opacity: 0.08; }
svg.focus .link.line { stroke-opacity: 0.08; }
svg.focus .link.band.on { fill-opacity: 0.78; }
svg.focus .link.line.on { stroke-opacity: 0.78; }
svg.focus .node { opacity: 0.3; }
svg.focus .node.on { opacity: 1; }
.node.picked .node-shape { stroke: var(--purple); stroke-width: 1.5; }
.lbl, .col-head, .band-label {
pointer-events: none; paint-order: stroke; stroke: #FFFFFF; stroke-width: 3px;
stroke-linecap: round; stroke-linejoin: round;
}
.lbl-name { font-weight: 600; fill: #1a1a1a; }
.lbl-value { fill: #666666; }
.col-head { font-weight: 700; fill: var(--purple); letter-spacing: 0.09em; text-transform: uppercase; }
/* Fiscal-year divider. geometricPrecision, not crispEdges: crisp snapping
would round the 0.5 stroke back up to a solid 1 px line. */
.band-rule { stroke-width: 0.5; shape-rendering: geometricPrecision; }
.band-label { font-weight: 700; letter-spacing: 0.06em; }
/* -------------------------------------------------------------- legend */
.legend {
display: flex; flex-wrap: wrap; gap: 6px 18px;
padding: 8px var(--page-inset) 0; font-size: 13.5px; color: #333;
}
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
.legend-item i { width: 11px; height: 11px; border-radius: 2px; display: inline-block; }
.legend-item b { width: 22px; height: 0; border-top: 1px solid #000; opacity: 0.7; display: inline-block; }
.grand-total {
padding: 12px var(--page-inset) 0;
font-size: 17px; line-height: 1.3; font-weight: 700; color: var(--purple);
}
footer {
margin-top: 14px; padding: 10px var(--page-inset) calc(18px + env(safe-area-inset-bottom));
border-top: 1px solid var(--rule);
font-size: 12.5px; line-height: 1.45; color: #595959;
display: flex; flex-direction: column; gap: 4px;
}
footer .scale-unit { font-weight: 700; }
/* ---------------------------------------------------------- detail card */
/* Opens directly under the tapped bar or ribbon, with a caret pointing up
at it. It is placed in the page, not the viewport, so it also lands in
the right place inside an iframe sized to its content, where
position:fixed would pin it to the far bottom of the frame. */
main { position: relative; }
.pop {
position: absolute; z-index: 20; top: 0; left: var(--page-inset);
background: #FFFFFF; border: 1px solid #D9CFE6; border-radius: 12px;
box-shadow: 0 10px 30px rgba(20, 10, 32, 0.22);
opacity: 0; transform: translateY(-4px);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.pop.shown { opacity: 1; transform: none; }
.pop[hidden] { display: none; }
#pop-reveal { position: absolute; left: 0; width: 1px; visibility: hidden; pointer-events: none; }
#pop-reveal[hidden] { display: none; }
.pop-caret {
position: absolute; top: -8px; left: 20px; width: 14px; height: 14px;
background: #FFFFFF; border: 1px solid #D9CFE6; border-right: 0; border-bottom: 0;
transform: rotate(45deg);
}
.pop-head {
position: relative; display: flex; align-items: flex-start; gap: 8px;
padding: 2px 4px 10px 14px; border-bottom: 1px solid #ECE6F3;
}
.pop-titles { flex: 1; min-width: 0; padding-top: 10px; }
.pop-eyebrow {
display: flex; align-items: center; gap: 6px;
font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #444;
}
.pop-eyebrow i { width: 9px; height: 9px; border-radius: 2px; }
.pop-title {
margin-top: 4px; font-size: 16px; line-height: 1.3; font-weight: 700; color: var(--purple);
}
.pop-close {
flex-shrink: 0; width: 44px; height: 44px; border: 0; border-radius: 999px;
background: transparent; color: #333; display: grid; place-items: center; cursor: pointer;
}
.pop-close:focus-visible { outline: 2px solid var(--purple); outline-offset: -2px; }
.pop-body { padding: 0 14px 14px; }
.pop-body > .pop-row:last-child { border-bottom: 0; }
.pop-section {
margin-top: 14px; font-size: 11.5px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--purple);
}
.pop-row {
display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
padding: 9px 0; border-bottom: 1px solid #EEEEEE; font-size: 14.5px; line-height: 1.35;
}
.pop-row .k { color: #333; min-width: 0; }
.pop-row .k i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 7px; }
.pop-row .v { font-weight: 600; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.pop-row.total .k, .pop-row.total .v { font-weight: 700; color: #000; }
.pop-meta { margin-top: 12px; font-size: 13.5px; line-height: 1.5; color: #444; }
.pop-meta strong { color: #1a1a1a; }
@media (prefers-reduced-motion: reduce) {
.pop, .link, .node, .views button { transition: none; }
}
/* hide the CMS DirectEdit "Updated" stamp injected into this standalone file */
a[href*="omniupdate.com"] { display: none !important; }
</style>
</head>
<body>
<div id="page">
<header>
<h1>K-State core facility funding and research impact flow diagram, FY24–FY25</h1>
<p class="subtitle">See how funding core research facilities translates to research impact across K-State.</p>
<p class="scale-note">Ribbon width = dollars left of projects, research groups right of projects.</p>
</header>
<nav class="views-bar" aria-label="Fiscal year">
<div class="views" id="views" role="group" aria-label="Fiscal year"></div>
</nav>
<main>
<div id="chart"></div>
<div class="legend" id="legend" aria-label="Colour key"></div>
<p class="grand-total" id="grand-total"></p>
<div id="pop-reveal" aria-hidden="true" hidden></div>
<section id="pop" class="pop" role="dialog" aria-modal="false" aria-labelledby="pop-title" hidden>
<div class="pop-caret" aria-hidden="true"></div>
<div class="pop-head">
<div class="pop-titles">
<div class="pop-eyebrow" id="pop-eyebrow"></div>
<h2 id="pop-title" class="pop-title"></h2>
</div>
<button id="pop-close" class="pop-close" type="button" aria-label="Close details">
<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 5l10 10M15 5L5 15"/></svg>
</button>
</div>
<div id="pop-body" class="pop-body"></div>
</section>
</main>
<footer>
<span>Tap a bar or ribbon for detail</span>
<span id="scale-readout"></span>
</footer>
</div>
<script>
// ---------------------------------------------------------------- palette
// Same values as core_facilities_sankey_v30.html.
const LAYER_COLORS = {0: "#512888", 1: "#9F694F", 2: "#CEA152"};
const LAYER2_TEXT_COLOR = "#835d09";
const FY_RULE_COLOR = "#000000";
const FY_CAPTION_COLOR = "#000000";
// ------------------------------------------------------------------- data
// Copied verbatim from core_facilities_sankey_v31.html. Regenerate both
// files from the same 03_render_sankey.py output so they never drift.
const DATA = {"nodes": [{"name": "One K-State fund", "layer": "0", "label": "One K-State fund", "fiscal_year": "", "sponsor_unit": "", "order": "0", "dollars_in": "0.00", "dollars_out": "956748.20", "groups_in": "0", "groups_out": "0"}, {"name": "Matching fund (various)", "layer": "0", "label": "Matching fund (various)", "fiscal_year": "", "sponsor_unit": "", "order": "1", "dollars_in": "0.00", "dollars_out": "956748.20", "groups_in": "0", "groups_out": "0"}, {"name": "Ackert Hall; Fume hood replacement", "layer": "1", "label": "Fume hood replacement", "fiscal_year": "FY24", "sponsor_unit": "Arts & Sciences - Arts & Sciences - Biology", "order": "0", "dollars_in": "340000.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "12"}, {"name": "Cardwell Hall Various labs; installation of emergency eyewash stations", "layer": "1", "label": "Installation of emergency eyewash stations", "fiscal_year": "FY24", "sponsor_unit": "Arts & Sciences - Arts & Sciences - Physics", "order": "1", "dollars_in": "60000.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "10"}, {"name": "Seaton Hall; Northwest HVAC", "layer": "1", "label": "Northwest HVAC", "fiscal_year": "FY24", "sponsor_unit": "Engineering - Engineering - Biological and Agricultural Engineering", "order": "2", "dollars_in": "465498.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "9"}, {"name": "Justin Hall Rm 130; Lab renovation", "layer": "1", "label": "Lab renovation, Rm 130", "fiscal_year": "FY24", "sponsor_unit": "Health & Human Sciences - Health & Human Sciences - Kinesiology", "order": "3", "dollars_in": "65498.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "5"}, {"name": "Repair of non-functioning Perchloric Acid Lab Hoods with wash system", "layer": "1", "label": "Repair Perchloric Acid Lab Hoods", "fiscal_year": "FY25", "sponsor_unit": "K-State Research and Extension; Dep of Agronomy", "order": "4", "dollars_in": "86916.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "255"}, {"name": "Extending AI Resources for KSU Researchers", "layer": "1", "label": "AI Resources for KSU Researchers", "fiscal_year": "FY25", "sponsor_unit": "College of Engineering", "order": "5", "dollars_in": "66227.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "150"}, {"name": "Acquisition of the Agilent’s 4150 Tape Station automated electrophoretic system", "layer": "1", "label": "Agilent electrophoresis 4150 TapeStation platform", "fiscal_year": "FY25", "sponsor_unit": "College of Agriculture and Department of Plant Pathology", "order": "6", "dollars_in": "33912.80", "dollars_out": "0.00", "groups_in": "0", "groups_out": "55"}, {"name": "Acquisition of a plate reader with multiple detection modes and integrated ACU/RI", "layer": "1", "label": "Multi-mode integrated ACU/RI plate reader", "fiscal_year": "FY25", "sponsor_unit": "Office of Research Development and College of A&S", "order": "7", "dollars_in": "72000.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "20"}, {"name": "Installation of Backup Power for IMEC", "layer": "1", "label": "Installation of Backup Power for IMEC", "fiscal_year": "FY25", "sponsor_unit": "Entomology", "order": "8", "dollars_in": "178895.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "14"}, {"name": "Reactor Control Room Upgrade", "layer": "1", "label": "Reactor Control Room Upgrade", "fiscal_year": "FY25", "sponsor_unit": "College of Engineering", "order": "9", "dollars_in": "94794.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "13"}, {"name": "Upgrading the irrigation system at KRV-Topeka", "layer": "1", "label": "Upgrading the irrigation system", "fiscal_year": "FY25", "sponsor_unit": "Agronomy", "order": "10", "dollars_in": "21419.80", "dollars_out": "0.00", "groups_in": "0", "groups_out": "12"}, {"name": "Acquisition of a Scanning Probe Microscope", "layer": "1", "label": "Scanning Probe Microscope", "fiscal_year": "FY25", "sponsor_unit": "Chemical Engineer, Civil Engineering, Industrial and Manufacturing Systems Engineering, and the College of Engineering", "order": "11", "dollars_in": "195020.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "10"}, {"name": "Large equipment purchase: Cytek Aurora Flow Cytometer", "layer": "1", "label": "Cytek Aurora Flow Cytometer", "fiscal_year": "FY25", "sponsor_unit": "Department of Diagnostic Medicine/Pathobiology", "order": "12", "dollars_in": "176000.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "9"}, {"name": "Expansion and Renovation of the K-State Fly Food Kitchen", "layer": "1", "label": "Fly Food Kitchen Expansion/Renovation", "fiscal_year": "FY25", "sponsor_unit": "Division of Biology", "order": "13", "dollars_in": "32500.00", "dollars_out": "0.00", "groups_in": "0", "groups_out": "5"}, {"name": "Purchase of a Replacement Nitrogen Generator for Analytical Pharmacology", "layer": "1", "label": "Replacement Nitrogen Generator", "fiscal_year": "FY25", "sponsor_unit": "Anatomy & Physiology- College of Vet Med", "order": "14", "dollars_in": "24815.80", "dollars_out": "0.00", "groups_in": "0", "groups_out": "5"}, {"name": "Ackert Hall", "layer": "2", "label": "Ackert Hall", "fiscal_year": "", "sponsor_unit": "", "order": "0", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "12", "groups_out": "0"}, {"name": "Cardwell Hall", "layer": "2", "label": "Cardwell Hall", "fiscal_year": "", "sponsor_unit": "", "order": "1", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "10", "groups_out": "0"}, {"name": "Seaton Hall", "layer": "2", "label": "Seaton Hall", "fiscal_year": "", "sponsor_unit": "", "order": "2", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "9", "groups_out": "0"}, {"name": "Justin Hall", "layer": "2", "label": "Justin Hall", "fiscal_year": "", "sponsor_unit": "", "order": "3", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "5", "groups_out": "0"}, {"name": "KSRE Soil Testing Laboratory", "layer": "2", "label": "KSRE Soil Testing Laboratory", "fiscal_year": "", "sponsor_unit": "", "order": "4", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "255", "groups_out": "0"}, {"name": "Beocat", "layer": "2", "label": "Beocat", "fiscal_year": "", "sponsor_unit": "", "order": "5", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "150", "groups_out": "0"}, {"name": "K-State Integrated Genomics Facility", "layer": "2", "label": "K-State Integrated Genomics Facility", "fiscal_year": "", "sponsor_unit": "", "order": "6", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "55", "groups_out": "0"}, {"name": "Protein and Biopolymer Analysis Core Lab", "layer": "2", "label": "Protein and Biopolymer Analysis Core Lab", "fiscal_year": "", "sponsor_unit": "", "order": "7", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "20", "groups_out": "0"}, {"name": "Integrated Molecular Entomology Core", "layer": "2", "label": "Integrated Molecular Entomology Core", "fiscal_year": "", "sponsor_unit": "", "order": "8", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "14", "groups_out": "0"}, {"name": "Triga Mark 2 Nuclear Reactor Facility", "layer": "2", "label": "Triga Mark 2 Nuclear Reactor Facility", "fiscal_year": "", "sponsor_unit": "", "order": "9", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "13", "groups_out": "0"}, {"name": "Kansas River Valley Experimental Field - Topeka", "layer": "2", "label": "Kansas River Valley Experimental Field - Topeka", "fiscal_year": "", "sponsor_unit": "", "order": "10", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "12", "groups_out": "0"}, {"name": "Materials Characterization Laboratory", "layer": "2", "label": "Materials Characterization Laboratory", "fiscal_year": "", "sponsor_unit": "", "order": "11", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "10", "groups_out": "0"}, {"name": "Flow Cytometry Laboratory", "layer": "2", "label": "Flow Cytometry Laboratory", "fiscal_year": "", "sponsor_unit": "", "order": "12", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "9", "groups_out": "0"}, {"name": "K-State Fly Food Kitchen", "layer": "2", "label": "K-State Fly Food Kitchen", "fiscal_year": "", "sponsor_unit": "", "order": "13", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "5", "groups_out": "0"}, {"name": "Analytical Pharmacology Core Lab", "layer": "2", "label": "Analytical Pharmacology Core Lab", "fiscal_year": "", "sponsor_unit": "", "order": "14", "dollars_in": "0.00", "dollars_out": "0.00", "groups_in": "5", "groups_out": "0"}], "links": [{"source": "One K-State fund", "target": "Ackert Hall; Fume hood replacement", "value": "170000.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Ackert Hall; Fume hood replacement", "value": "170000.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Cardwell Hall Various labs; installation of emergency eyewash stations", "value": "30000.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Cardwell Hall Various labs; installation of emergency eyewash stations", "value": "30000.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Seaton Hall; Northwest HVAC", "value": "232749.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Seaton Hall; Northwest HVAC", "value": "232749.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Justin Hall Rm 130; Lab renovation", "value": "32749.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Justin Hall Rm 130; Lab renovation", "value": "32749.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Repair of non-functioning Perchloric Acid Lab Hoods with wash system", "value": "43458.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Repair of non-functioning Perchloric Acid Lab Hoods with wash system", "value": "43458.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Extending AI Resources for KSU Researchers", "value": "33113.50", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Extending AI Resources for KSU Researchers", "value": "33113.50", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Acquisition of the Agilent’s 4150 Tape Station automated electrophoretic system", "value": "16956.40", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Acquisition of the Agilent’s 4150 Tape Station automated electrophoretic system", "value": "16956.40", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Acquisition of a plate reader with multiple detection modes and integrated ACU/RI", "value": "36000.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Acquisition of a plate reader with multiple detection modes and integrated ACU/RI", "value": "36000.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Installation of Backup Power for IMEC", "value": "89447.50", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Installation of Backup Power for IMEC", "value": "89447.50", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Reactor Control Room Upgrade", "value": "47397.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Reactor Control Room Upgrade", "value": "47397.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Upgrading the irrigation system at KRV-Topeka", "value": "10709.90", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Upgrading the irrigation system at KRV-Topeka", "value": "10709.90", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Acquisition of a Scanning Probe Microscope", "value": "97510.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Acquisition of a Scanning Probe Microscope", "value": "97510.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Large equipment purchase: Cytek Aurora Flow Cytometer", "value": "88000.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Large equipment purchase: Cytek Aurora Flow Cytometer", "value": "88000.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Expansion and Renovation of the K-State Fly Food Kitchen", "value": "16250.00", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Expansion and Renovation of the K-State Fly Food Kitchen", "value": "16250.00", "unit": "dollars", "link_group": "funding"}, {"source": "One K-State fund", "target": "Purchase of a Replacement Nitrogen Generator for Analytical Pharmacology", "value": "12407.90", "unit": "dollars", "link_group": "funding"}, {"source": "Matching fund (various)", "target": "Purchase of a Replacement Nitrogen Generator for Analytical Pharmacology", "value": "12407.90", "unit": "dollars", "link_group": "funding"}, {"source": "Ackert Hall; Fume hood replacement", "target": "Ackert Hall", "value": "12", "unit": "groups", "link_group": "impact"}, {"source": "Cardwell Hall Various labs; installation of emergency eyewash stations", "target": "Cardwell Hall", "value": "10", "unit": "groups", "link_group": "impact"}, {"source": "Seaton Hall; Northwest HVAC", "target": "Seaton Hall", "value": "9", "unit": "groups", "link_group": "impact"}, {"source": "Justin Hall Rm 130; Lab renovation", "target": "Justin Hall", "value": "5", "unit": "groups", "link_group": "impact"}, {"source": "Repair of non-functioning Perchloric Acid Lab Hoods with wash system", "target": "KSRE Soil Testing Laboratory", "value": "255", "unit": "groups", "link_group": "impact"}, {"source": "Extending AI Resources for KSU Researchers", "target": "Beocat", "value": "150", "unit": "groups", "link_group": "impact"}, {"source": "Acquisition of the Agilent’s 4150 Tape Station automated electrophoretic system", "target": "K-State Integrated Genomics Facility", "value": "55", "unit": "groups", "link_group": "impact"}, {"source": "Acquisition of a plate reader with multiple detection modes and integrated ACU/RI", "target": "Protein and Biopolymer Analysis Core Lab", "value": "20", "unit": "groups", "link_group": "impact"}, {"source": "Installation of Backup Power for IMEC", "target": "Integrated Molecular Entomology Core", "value": "14", "unit": "groups", "link_group": "impact"}, {"source": "Reactor Control Room Upgrade", "target": "Triga Mark 2 Nuclear Reactor Facility", "value": "13", "unit": "groups", "link_group": "impact"}, {"source": "Upgrading the irrigation system at KRV-Topeka", "target": "Kansas River Valley Experimental Field - Topeka", "value": "12", "unit": "groups", "link_group": "impact"}, {"source": "Acquisition of a Scanning Probe Microscope", "target": "Materials Characterization Laboratory", "value": "10", "unit": "groups", "link_group": "impact"}, {"source": "Large equipment purchase: Cytek Aurora Flow Cytometer", "target": "Flow Cytometry Laboratory", "value": "9", "unit": "groups", "link_group": "impact"}, {"source": "Expansion and Renovation of the K-State Fly Food Kitchen", "target": "K-State Fly Food Kitchen", "value": "5", "unit": "groups", "link_group": "impact"}, {"source": "Purchase of a Replacement Nitrogen Generator for Analytical Pharmacology", "target": "Analytical Pharmacology Core Lab", "value": "5", "unit": "groups", "link_group": "impact"}]};
DATA.nodes.forEach(n => {
n.layer = +n.layer;
// d3-sankey overwrites node.layer, so the authored column lives in `col`
// and nothing below reads .layer after layout.
n.col = n.layer;
n.order = +n.order;
n.dollars_in = +n.dollars_in;
n.dollars_out = +n.dollars_out;
n.groups_in = +n.groups_in;
n.groups_out = +n.groups_out;
});
DATA.links.forEach(l => { l.value = +l.value; });
// Fiscal-year views, derived from the data: "All" plus one per year present.
const ALL = "All";
const FISCAL_YEARS = Array.from(new Set(
DATA.nodes.filter(n => n.col === 1).map(n => n.fiscal_year))).sort();
const VIEWS = [ALL].concat(FISCAL_YEARS).map(key => ({
key,
label: key,
count: DATA.nodes.filter(n => n.col === 1 && (key === ALL || n.fiscal_year === key)).length
}));
let fyFilter = ALL;
// ------------------------------------------------------------- constants
const W_MIN = 2; // thinnest drawn ribbon, px (widened, never clamped)
const NODE_W = 12; // bar width, px
const LANE_GUTTER = 10; // clear space between label lanes, px
const LABEL_GAP = 5; // label block bottom to bar top, px
const GAP = 14; // vertical gap between projects, px
const GAP_MIN = 10; // smallest gap between stacked facilities, px
const HEAD_H = 24; // band reserved for the column heads, px
const FY_HEAD_H = 24; // band reserved for a fiscal-year header, px
const FY_RULE_Y = 15; // rule offset inside that band, px
const BAR_HIT_W = 32; // tap width around a bar, px
const BAND_MIN = 12; // ribbons this wide or wider draw as filled bands
const TAP_MIN = 44; // minimum tap target height, px
// Column titles, indexed by column. Used by both the column heads and the
// legend key, as on desktop.
const COLUMN_TITLES = ["Funding sources", "Projects", "Research Groups"];
const COLUMN_TITLES_SHORT = ["Funding", "Projects", "Groups"];
const usd0 = d3.format("$,.0f");
const usd2 = d3.format("$,.2f");
// Whole dollars print without cents; split amounts such as $16,956.40 keep them.
const usd = v => Math.abs(v - Math.round(v)) < 0.005 ? usd0(v) : usd2(v);
const int = d3.format(",d");
const groupsText = (v, long) => `${int(v)} ${long ? "research " : ""}group${v === 1 ? "" : "s"}`;
// Type sizes step down one notch on the narrowest phones.
function typeFor(W) {
const name = W < 375 ? 12.5 : 13;
return { name, value: name - 1, lh: Math.round(name * 1.25), head: 11, fy: 11.5 };
}
// --------------------------------------------------------- text measuring
// Labels are wrapped by MEASURED width, on the device's own font, so the
// lanes fit whatever system face the phone renders with.
const FONT = getComputedStyle(document.body).fontFamily;
const measureCtx = document.createElement("canvas").getContext("2d");
function textW(str, font) { measureCtx.font = font; return measureCtx.measureText(str).width; }
// Break at spaces, and after a hyphen only between parts of 3+ characters
// ("Expansion/Renovation" stays whole, "K-State" never breaks). A short
// parenthetical, and a spaced dash ("Field - Topeka"), is glued to the word
// before it so it never opens a line.
function tokenize(text) {
const glued = String(text).replace(/ (\(.{1,3}\))/g, " $1").replace(/ ([-–—]) /g, " $1 ");
const tokens = [];
glued.split(/[ \t\n]+/).filter(Boolean).forEach((word, wi) => {
const parts = [];
word.replace(/-/g, "-\u0000").split("\u0000").filter(Boolean).forEach(p => {
const letters = p.replace(/-$/, "").length;
if (parts.length && (letters < 3 || parts[parts.length - 1].replace(/-$/, "").length < 3)) {
parts[parts.length - 1] += p;
} else parts.push(p);
});
parts.forEach((part, pi) => tokens.push({ t: part, sp: wi > 0 && pi === 0 }));
});
return tokens;
}
function greedy(tokens, maxW, font) {
const lines = [];
let cur = "";
tokens.forEach(tk => {
const cand = cur ? cur + (tk.sp ? " " : "") + tk.t : tk.t;
if (!cur || textW(cand, font) <= maxW) { cur = cand; return; }
lines.push(cur);
cur = tk.t;
});
if (cur) lines.push(cur);
return lines.length ? lines : [""];
}
// Wrap to maxW, then narrow the measure as far as it goes without adding a
// line, so the lines come out balanced rather than leaving a lone word.
function wrapPx(text, maxW, font, maxLines) {
const tokens = tokenize(text);
let lines = greedy(tokens, maxW, font);
if (lines.length > 1 && lines.length <= maxLines) {
let lo = d3.max(tokens, tk => textW(tk.t, font)), hi = maxW;
for (let i = 0; i < 10 && hi - lo > 1; i++) {
const mid = (lo + hi) / 2;
if (greedy(tokens, mid, font).length <= lines.length) hi = mid; else lo = mid;
}
lines = greedy(tokens, hi, font);
}
if (lines.length <= maxLines) return lines;
const kept = lines.slice(0, maxLines);
let last = kept[maxLines - 1];
while (last.length > 1 && textW(last + "…", font) > maxW) last = last.slice(0, -1).trimEnd();
kept[maxLines - 1] = last + "…";
return kept;
}
function esc(s) {
return String(s === undefined || s === null ? "" : s)
.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
function copyList(list) { return list.map(d => Object.assign({}, d)); }
function linkKey(l) { return l.source.name + "→" + l.target.name; }
// Label text per column. A core facility runs it the other way round, as on
// desktop: the group count is the headline and the facility name sits under
// it, because the column measures research groups.
function labelName(n) { return n.col === 2 ? groupsText(n._groups) : n.label; }
function labelValues(n, laneW, font) {
if (n.col === 0) return [usd0(n._dollars)];
if (n.col === 2) return [n.label];
const one = `${usd0(n._dollars)} · ${groupsText(n._groups)}`;
return textW(one, font) <= laneW ? [one] : [usd0(n._dollars), groupsText(n._groups)];
}
// ------------------------------------------------------------------ state
let graph = null; // the laid-out graph of the current render
let selected = null; // { type: "node" | "link", key }
let lastWidth = 0;
let scaleText = { d: "", g: "" };
// ================================================================= render
function render() {
const host = document.getElementById("chart");
d3.select(host).selectAll("*").remove();
const W = Math.max(320, Math.round(host.clientWidth));
lastWidth = W;
const inset = parseFloat(getComputedStyle(document.documentElement)
.getPropertyValue("--page-inset")) || 16;
const T = typeFor(W);
const NAME_FONT = `600 ${T.name}px ${FONT}`;
const VALUE_FONT = `400 ${T.value}px ${FONT}`;
const HEAD_FONT = `700 ${T.head}px ${FONT}`;
const FY_FONT = `700 ${T.fy}px ${FONT}`;
// ------------------------------------------ split by unit / column / year
// The fiscal-year filter is applied here and nowhere else; the layout, the
// two scales, the year headers and the grand total all follow from it.
const projects = DATA.nodes.filter(n => n.col === 1
&& (fyFilter === ALL || n.fiscal_year === fyFilter));
const kept = new Set(projects.map(n => n.name));
const dollarLinks = DATA.links.filter(l => l.unit === "dollars" && kept.has(l.target));
const groupLinks = DATA.links.filter(l => l.unit === "groups" && kept.has(l.source));
// Funds and facilities follow the surviving links, not the column alone.
const fed = new Set(dollarLinks.map(l => l.source));
const reached = new Set(groupLinks.map(l => l.target));
const funds = DATA.nodes.filter(n => n.col === 0 && fed.has(n.name));
const facilities = DATA.nodes.filter(n => n.col === 2 && reached.has(n.name));
// d3.sankey() throws on an empty node set; a blank page is a poor failure
// mode for a filter.
if (!projects.length) {
graph = null;
d3.select(host).append("p").attr("class", "empty-state").text(`No projects in ${fyFilter}.`);
d3.select("#scale-readout").text("");
d3.select("#grand-total").text("");
return;
}
// ---------------------------------------------------- d3-sankey layout
// One graph holds both halves. d3-sankey builds it, wires sourceLinks and
// targetLinks, and assigns the three column x positions. Ribbon widths
// (two scales) and the vertical axis are then set below so each label's
// height is reserved above its bar, and d3-sankey re-stacks the ribbon
// ends from them in sankey.update().
const sankey = d3.sankey()
.nodeId(d => d.name)
.nodeAlign(n => n.col)
.nodeWidth(NODE_W)
.nodePadding(0)
.nodeSort((a, b) => a.order - b.order)
.linkSort(null)
.extent([[inset, HEAD_H], [W - inset, HEAD_H + 400]]);
const g = sankey({
nodes: copyList(funds.concat(projects, facilities)),
links: copyList(dollarLinks.concat(groupLinks))
});
graph = g;
// Totals in view, summed from the links still on the canvas. The stored
// dollars_out of a fund is the whole-dataset figure; under a filter it
// would name money that is no longer drawn.
g.nodes.forEach(n => {
n._dollars = n.col === 0 ? d3.sum(n.sourceLinks, l => l.value)
: n.col === 1 ? d3.sum(n.targetLinks, l => l.value) : 0;
n._groups = n.col === 1 ? d3.sum(n.sourceLinks, l => l.value)
: n.col === 2 ? d3.sum(n.targetLinks, l => l.value) : 0;
});
const cols = new Map([0, 1, 2].map(c => [c, g.nodes.filter(n => n.col === c)]));
cols.get(0).sort((a, b) => a.order - b.order);
cols.get(2).sort((a, b) => a.order - b.order);
// Projects are grouped by fiscal year first, so each year is one block
// under one header even if the authored order ever interleaves them.
cols.get(1).sort((a, b) => d3.ascending(a.fiscal_year, b.fiscal_year) || a.order - b.order);
// --------------------------------------------------------- label lanes
// Each edge lane is as wide as its widest wrapped line (capped), and the
// centre lane takes what is left. The fund lane holds only two short
// labels, so it gets the smaller cap and the project lane gains the room.
function laneFit(nodes, cap) {
let w = 0;
nodes.forEach(n => {
wrapPx(labelName(n), cap, NAME_FONT, 3).forEach(l => { w = Math.max(w, textW(l, NAME_FONT)); });
labelValues(n, cap, VALUE_FONT).forEach(v => wrapPx(v, cap, VALUE_FONT, 3)
.forEach(l => { w = Math.max(w, textW(l, VALUE_FONT)); }));
});
return Math.ceil(Math.min(cap, w));
}
const leftW = Math.max(NODE_W, laneFit(cols.get(0), Math.round(W * 0.24)));
const rightW = Math.max(NODE_W, laneFit(cols.get(2), Math.round(W * 0.30)));
const centerW = W - 2 * inset - leftW - rightW - 2 * LANE_GUTTER;
const lanes = {
0: { x0: inset, w: leftW, anchor: "start", tx: inset },
1: { x0: inset + leftW + LANE_GUTTER, w: centerW, anchor: "middle" },
2: { x0: W - inset - rightW, w: rightW, anchor: "end", tx: W - inset },
};
lanes[1].tx = lanes[1].x0 + centerW / 2;
// Projects sit on the centre of their lane, which is not always the
// centre of the screen once the edge lanes size to their content.
cols.get(1).forEach(n => { n.x0 = lanes[1].tx - NODE_W / 2; n.x1 = n.x0 + NODE_W; });
g.nodes.forEach(n => {
const lane = lanes[n.col];
const names = wrapPx(labelName(n), lane.w, NAME_FONT, n.col === 1 ? 4 : 3);
const vals = [];
labelValues(n, lane.w, VALUE_FONT)
.forEach(v => wrapPx(v, lane.w, VALUE_FONT, n.col === 2 ? 3 : 2).forEach(l => vals.push(l)));
n._lines = names.map(t => ({ t, name: true })).concat(vals.map(t => ({ t, name: false })));
n._labelH = n._lines.length * T.lh + LABEL_GAP;
});
// ------------------------------------------------------------- scales
// Two pixels-per-unit factors, as on desktop: dollars for every ribbon
// left of the projects, research groups for every ribbon right of them.
// Each half's total is drawn at the same bar budget P, so each half reads
// at full strength; the footer quotes both scales. The page scrolls, so
// P is a legibility choice rather than a fit to the viewport.
const P = Math.round(Math.min(420, Math.max(300, W * 0.82)));
const kyD = P / (d3.sum(dollarLinks, l => l.value) || 1);
const kyG = P / (d3.sum(groupLinks, l => l.value) || 1);
g.links.forEach(l => { l.width = Math.max(W_MIN, l.value * (l.unit === "dollars" ? kyD : kyG)); });
// A node face is drawn as the exact sum of the ribbons touching it, so
// these are the single source of truth for every bar height.
const faceIn = n => d3.sum(n.targetLinks, l => l.width);
const faceOut = n => d3.sum(n.sourceLinks, l => l.width);
const faceH = n => Math.max(faceIn(n), faceOut(n));
// ----------------------------------------------------------- repack
// 1. Projects, the busiest column, are stacked top to bottom: a year
// header where the fiscal year changes, then each label block over its
// bar. Their total sets the chart height.
//
// Each project is packed as a ROW with the facility it feeds: the row's
// label band is as tall as the taller of the two label blocks, so the
// facility can sit level with the project (step 2) and its research-
// group ribbon runs flat. A facility fed by several projects rows with
// the first of them.
const projCol = cols.get(1);
const partners = new Map(projCol.map(p => [p, []]));
cols.get(2).forEach(f => {
const first = f.targetLinks.map(l => l.source)
.sort((a, b) => projCol.indexOf(a) - projCol.indexOf(b))[0];
if (first) partners.get(first).push(f);
});
const fyHeads = [];
let y = HEAD_H, prevFy = null;
projCol.forEach((n, i) => {
if (n.fiscal_year !== prevFy) {
prevFy = n.fiscal_year;
y += i ? GAP + 6 : 2;
fyHeads.push({ fy: n.fiscal_year, y, n: projCol.filter(m => m.fiscal_year === n.fiscal_year).length });
y += FY_HEAD_H;
} else {
y += GAP;
}
const rowH = d3.max([n].concat(partners.get(n)), m => m._labelH);
n._labelTop = y + rowH - n._labelH;
n.y0 = y + rowH;
n.y1 = n.y0 + faceH(n);
y = n.y1;
});
let bottom = y + GAP;
// 2. Each facility is drawn level with the project that feeds it, so the
// research-group ribbons run flat. The rows above already made room for
// its label; the floor only matters for a facility fed by several
// projects, which is pushed down rather than overlap -- never up.
let floor = HEAD_H + 2;
cols.get(2).forEach(n => {
const want = n.targetLinks.length ? d3.min(n.targetLinks, l => l.source.y0) : 0;
n.y0 = Math.max(want, floor + n._labelH);
n._labelTop = n.y0 - n._labelH;
n.y1 = n.y0 + faceH(n);
floor = n.y1 + GAP_MIN;
});
bottom = Math.max(bottom, floor + GAP - GAP_MIN);
// 3. Funds share the height that results, equal whitespace above, between
// and below, as on the reference mobile chart.
const fundCol = cols.get(0);
const fUsed = d3.sum(fundCol, n => n._labelH + faceH(n));
const fGap = Math.max(GAP, (bottom - HEAD_H - fUsed) / (fundCol.length + 1));
y = HEAD_H + fGap;
fundCol.forEach(n => {
n._labelTop = y;
n.y0 = y + n._labelH;
n.y1 = n.y0 + faceH(n);
y = n.y1 + fGap;
});
bottom = Math.max(bottom, y);
const H = Math.ceil(bottom);
// Stack ribbon ends in the order of the node at their other end, so
// ribbons leave and arrive without crossing at the bars.
g.nodes.forEach(n => {
n.sourceLinks.sort((a, b) => a.target.y0 - b.target.y0 || a.target.order - b.target.order);
n.targetLinks.sort((a, b) => a.source.y0 - b.source.y0 || a.source.order - b.source.order);
});
sankey.update(g);
// --------------------------------------------------------------- svg
const svg = d3.select(host).append("svg")
.attr("width", W).attr("height", H)
.attr("viewBox", [0, 0, W, H])
.attr("role", "group")
.attr("aria-label", "Sankey diagram of K-State core facility funding: funding sources, projects and research groups")
.on("click", () => clearSelection());
const defs = svg.append("defs");
g.links.forEach((l, i) => {
l.gradientId = `grad-${fyFilter}-${i}`;
const grad = defs.append("linearGradient")
.attr("id", l.gradientId).attr("gradientUnits", "userSpaceOnUse")
.attr("x1", l.source.x1).attr("x2", l.target.x0);
grad.append("stop").attr("offset", "0%").attr("stop-color", LAYER_COLORS[l.source.col]);
grad.append("stop").attr("offset", "100%").attr("stop-color", LAYER_COLORS[l.target.col]);
});
// Column heads, each falling back to its short title if it would run
// into the centre one.
const headW = t => textW(t.toUpperCase(), HEAD_FONT) + t.length * T.head * 0.09;
const titles = COLUMN_TITLES.slice();
const centreHalf = headW(titles[1]) / 2;
if (inset + headW(titles[0]) + 12 > lanes[1].tx - centreHalf) titles[0] = COLUMN_TITLES_SHORT[0];
if (W - inset - headW(titles[2]) - 12 < lanes[1].tx + centreHalf) titles[2] = COLUMN_TITLES_SHORT[2];
svg.append("g").selectAll("text")
.data([0, 1, 2]).join("text")
.attr("class", "col-head")
.attr("x", c => lanes[c].tx).attr("y", 12)
.attr("text-anchor", c => lanes[c].anchor)
.attr("font-size", T.head)
.text(c => titles[c]);
// ------------------------------------------------------------ ribbons
// The band between two horizontal-tangent curves, built from d3-sankey's
// own link.y0 / link.y1 / link.width and the same midpoint control points
// as d3.sankeyLinkHorizontal.
function ribbonPath(l) {
const x0 = l.source.x1, x1 = l.target.x0, xm = (x0 + x1) / 2, h = l.width / 2;
const a0 = l.y0 - h, b0 = l.y0 + h, a1 = l.y1 - h, b1 = l.y1 + h;
return `M${x0},${a0}C${xm},${a0} ${xm},${a1} ${x1},${a1}`
+ `L${x1},${b1}C${xm},${b1} ${xm},${b0} ${x0},${b0}Z`;
}
const centreLine = d3.sankeyLinkHorizontal();
const onLink = (event, d) => { event.stopPropagation(); select({ type: "link", key: linkKey(d) }, event); };
// A wide invisible stroke under each thin ribbon so a 2 px ribbon is
// still tappable. Visible ribbons sit above, so where they overlap the
// one you can see is the one you get.
svg.append("g").attr("class", "link-hits")
.selectAll("path").data(g.links.filter(l => l.width < 16)).join("path")
.attr("class", "link-hit")
.attr("d", centreLine)
.attr("stroke-width", 16)
.on("click", onLink);
const isBand = l => l.width >= BAND_MIN;
svg.append("g").attr("class", "links")
.selectAll("path").data(g.links).join("path")
.attr("class", l => isBand(l) ? "link band" : "link line")
.attr("d", l => isBand(l) ? ribbonPath(l) : centreLine(l))
.attr("fill", l => isBand(l) ? `url(#${l.gradientId})` : null)
.attr("stroke", l => isBand(l) ? null : `url(#${l.gradientId})`)
.attr("stroke-width", l => isBand(l) ? null : l.width)
.on("click", onLink);
// ---------------------------------------------------------- FY headers
// The desktop's fiscal-year divider, as a section header in the project
// lane: a caption resting on a hairline, in a band of its own that the
// packing above reserved, so it can never sit on a label. Drawn over the
// ribbons it crosses, like the desktop rule over the funding fan.
const fyG = svg.append("g").attr("class", "bands").attr("aria-hidden", "true");
fyHeads.forEach(h => {
let caption = `${h.fy} · ${h.n} project${h.n === 1 ? "" : "s"}`;
if (textW(caption, FY_FONT) + caption.length * T.fy * 0.06 > centerW - 8) caption = `${h.fy} · ${h.n}`;
fyG.append("line").attr("class", "band-rule")
.attr("x1", lanes[1].x0).attr("x2", lanes[1].x0 + centerW)
.attr("y1", h.y + FY_RULE_Y).attr("y2", h.y + FY_RULE_Y)
.attr("stroke", FY_RULE_COLOR);
fyG.append("text").attr("class", "band-label")
.attr("x", lanes[1].tx).attr("y", h.y + FY_RULE_Y - 4)
.attr("text-anchor", "middle")
.attr("font-size", T.fy)
.attr("fill", FY_CAPTION_COLOR)
.text(caption);
});
// -------------------------------------------------------------- nodes
// Funds and facilities are plain bars. A project's left edge is the sum
// of the dollar ribbons arriving and its right edge the sum of the group
// ribbons leaving -- two scales -- so it draws as a wedge that says so.
function nodePath(n) {
const hIn = faceIn(n), hOut = faceOut(n);
const left = hIn > 0 ? hIn : hOut;
const right = hOut > 0 ? hOut : hIn;
return `M${n.x0},${n.y0} L${n.x1},${n.y0} L${n.x1},${n.y0 + right} L${n.x0},${n.y0 + left} Z`;
}
const nodeG = svg.append("g").attr("class", "nodes")
.selectAll("g").data(g.nodes).join("g")
.attr("class", "node")
.attr("tabindex", 0)
.attr("role", "button")
.attr("aria-label", n => `${n._lines.filter(l => l.name).map(l => l.t).join(" ")}. `
+ `${labelValues(n, Infinity, VALUE_FONT).join(", ")}. Show details`)
.on("click", (event, n) => { event.stopPropagation(); select({ type: "node", key: n.name }, event); })
.on("keydown", (event, n) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
select({ type: "node", key: n.name }, event);
}
});
// Tap area: the whole label block across its lane, plus a column around
// the bar, at least TAP_MIN tall in all.
const tapH = n => Math.max(TAP_MIN, n.y1 - n._labelTop + 4);
nodeG.append("rect").attr("class", "node-hit")
.attr("x", n => lanes[n.col].x0).attr("y", n => n._labelTop - 2)
.attr("width", n => lanes[n.col].w).attr("height", n => n._labelH + 2);
nodeG.append("rect").attr("class", "node-hit")
.attr("x", n => (n.x0 + n.x1) / 2 - BAR_HIT_W / 2).attr("y", n => n._labelTop - 2)
.attr("width", BAR_HIT_W).attr("height", tapH);
nodeG.append("rect").attr("class", "node-ring")
.attr("x", n => Math.min(lanes[n.col].x0, n.x0) - 3).attr("y", n => n._labelTop - 4)
.attr("width", n => Math.max(lanes[n.col].x0 + lanes[n.col].w, n.x1) - Math.min(lanes[n.col].x0, n.x0) + 6)
.attr("height", n => n.y1 - n._labelTop + 8).attr("rx", 4);
nodeG.append("path")
.attr("class", "node-shape")
.attr("d", nodePath)
.attr("fill", n => LAYER_COLORS[n.col]);
// Labels: name lines then value lines, stacked on top of the bar and
// anchored to the lane (left, centre, right).
nodeG.each(function (n) {
const cell = d3.select(this);
const lane = lanes[n.col];
const x = n.col === 1 ? (n.x0 + n.x1) / 2 : lane.tx;
n._lines.forEach((line, i) => {
cell.append("text")
.attr("class", line.name ? "lbl lbl-name" : "lbl lbl-value")
.attr("x", x)
.attr("y", n._labelTop + i * T.lh + T.lh * 0.78)
.attr("text-anchor", lane.anchor)
.attr("font-size", line.name ? T.name : T.value)
.text(line.t);
});
});
// ------------------------------------------------------ scale + total
// Quoted as the MIN ratio: a lifted ribbon sits above the true scale, an
// unlifted one is exactly on it. Below one unit per px the groups scale
// flips to px per group, so a large scale never prints as "0.0 groups".
const kD = d3.min(g.links.filter(l => l.unit === "dollars"), l => l.width / l.value) || kyD;
const kG = d3.min(g.links.filter(l => l.unit === "groups"), l => l.width / l.value) || kyG;
scaleText = {
d: `1 px ≈ ${usd0(1 / kD)}`,
g: 1 / kG >= 1 ? `1 px ≈ ${(1 / kG).toFixed(1)} groups` : `1 group ≈ ${kG.toFixed(1)} px`
};
d3.select("#scale-readout").html(
`Ribbon width = <span class="scale-unit" style="color:${LAYER_COLORS[0]}">dollars</span>`
+ ` left of projects (${scaleText.d}), `
+ `<span class="scale-unit" style="color:${LAYER2_TEXT_COLOR}">research groups</span>`
+ ` right of projects (${scaleText.g}).`);
// Worded as engagements on purpose, as on desktop: a group using two
// facilities contributes to two links, so this is not a headcount of
// distinct groups. Summed from the data, never hardcoded.
const groupTotal = d3.sum(cols.get(2), n => n._groups);
d3.select("#grand-total").text(`${int(groupTotal)} research groups impacted`);
applySelection();
}
// ================================================================= legend
function renderLegend() {
const legend = d3.select("#legend");
legend.selectAll("*").remove();
COLUMN_TITLES.forEach((title, c) => {
const s = legend.append("span").attr("class", "legend-item");
s.append("i").attr("aria-hidden", "true").style("background", LAYER_COLORS[c]);
s.append("span").text(title);
});
const fy = legend.append("span").attr("class", "legend-item");
fy.append("b").attr("aria-hidden", "true").style("border-top-color", FY_RULE_COLOR);
fy.append("span").text("Fiscal year divider");
}
// ============================================================== selection
function findSelected() {
if (!selected || !graph) return null;
if (selected.type === "node") return graph.nodes.find(n => n.name === selected.key) || null;
return graph.links.find(l => linkKey(l) === selected.key) || null;
}
function applySelection() {
const svg = d3.select("#chart svg");
const item = findSelected();
if (!item) {
svg.classed("focus", false);
svg.selectAll(".link, .node").classed("on", false).classed("picked", false);
return;
}
const onNodes = new Set(), onLinks = new Set();
if (selected.type === "node") {
onNodes.add(item);
item.sourceLinks.concat(item.targetLinks).forEach(l => {
onLinks.add(l); onNodes.add(l.source); onNodes.add(l.target);
});
} else {
onLinks.add(item); onNodes.add(item.source); onNodes.add(item.target);
}
svg.classed("focus", true);
svg.selectAll(".link").classed("on", d => onLinks.has(d));
svg.selectAll(".node").classed("on", d => onNodes.has(d))
.classed("picked", d => selected.type === "node" && d === item);
}
let returnFocus = null;
function select(sel, event) {
selected = sel;
applySelection();
const item = findSelected();
if (!item) return;
const viaKeyboard = !!event && event.type === "keydown";
if (event) returnFocus = viaKeyboard ? event.currentTarget : null;
const content = selected.type === "node" ? nodeDetail(item) : linkDetail(item);
openPop(content, viaKeyboard, anchorFor(item, event));
}
function clearSelection() {
if (!selected) return;
selected = null;
applySelection();
closePop();
}
// ============================================================ detail card
// Same pattern as open_call_sankey_mobile_v2.html.
const pop = document.getElementById("pop");
const popReveal = document.getElementById("pop-reveal");
const pageEl = document.getElementById("page");
const mainEl = document.querySelector("main");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const POP_MAX_W = 440; // card width cap, px (it spans the page on a phone)
const POP_GAP = 12; // item edge to card edge, px (the caret sits in it)
const TALL_BAR = 150; // bars taller than this open the card where tapped
// What the card points at, in the chart svg's coordinates: an x for the
// caret and the item's top and bottom edges. A node opens under its bar
// (labels sit above bars, so that is under the whole item); a tall bar --
// a fund, or the KSRE and Beocat facilities -- opens where it was tapped
// instead of far below; a ribbon opens under the tap point.
function anchorFor(item, event) {
const box = document.querySelector("#chart svg").getBoundingClientRect();
const tap = event && event.type !== "keydown" && event.clientX !== undefined
? { x: event.clientX - box.left, y: event.clientY - box.top } : null;
if (selected.type === "node") {
const n = item, x = (n.x0 + n.x1) / 2;
if (n.y1 - n.y0 <= TALL_BAR) return { x, top: n._labelTop, bottom: n.y1 };
const y = tap ? Math.max(n.y0 + 12, Math.min(n.y1, tap.y)) : n.y0 + 12;
return { x, top: Math.min(n._labelTop, y), bottom: y };
}
if (tap) return { x: tap.x, top: tap.y, bottom: tap.y };
const l = item, y = (l.y0 + l.y1) / 2;
return { x: (l.source.x1 + l.target.x0) / 2, top: y - l.width / 2, bottom: y + l.width / 2 };
}
function openPop({ eyebrow, title, html }, focusClose, a) {
document.getElementById("pop-eyebrow").innerHTML =
`<i style="background:${esc(eyebrow.color)}" aria-hidden="true"></i>` + esc(eyebrow.text);
document.getElementById("pop-title").textContent = title;
document.getElementById("pop-body").innerHTML = html;
pop.classList.remove("shown");
pop.hidden = false;
// Lay the card out in <main>'s coordinates: full width on a phone (capped
// on wider screens and centred on the item), caret over the item.
const inset = parseFloat(getComputedStyle(document.documentElement)
.getPropertyValue("--page-inset")) || 16;
const mainBox = mainEl.getBoundingClientRect();
const svgBox = document.querySelector("#chart svg").getBoundingClientRect();
const offX = svgBox.left - mainBox.left, offY = svgBox.top - mainBox.top;
const W = mainEl.clientWidth;
const w = Math.min(POP_MAX_W, W - 2 * inset);
const ax = offX + a.x;
const left = Math.max(inset, Math.min(W - inset - w, ax - w / 2));
pop.style.width = w + "px";
pop.style.left = left + "px";
pop.querySelector(".pop-caret").style.left = Math.max(14, Math.min(w - 28, ax - left - 7)) + "px";
// Always under the item. Near the end of the page the card simply runs
// past it: the page grows to hold it (and so does the host's frame).
pop.style.top = Math.round(offY + a.bottom + POP_GAP) + "px";
// What has to be on screen afterwards: the item (its last 120 px at
// most) down to the bottom of the card. An invisible box spans exactly
// that, and is what gets scrolled into view.
const itemH = Math.min(120, a.bottom - a.top);
popReveal.hidden = false;
const revealTop = offY + a.bottom - itemH - 8;
popReveal.style.top = Math.round(revealTop) + "px";
popReveal.style.height = Math.round(offY + a.bottom + POP_GAP + pop.offsetHeight + 16 - revealTop) + "px";
popReveal.style.scrollMarginTop = document.querySelector(".views-bar").offsetHeight + "px";
// Bring the card into view: this page scrolls, or the host page when
// embedded. A host on another origin resizes the frame only when our
// height message arrives, so wait for that resize before scrolling.
const sized = reportHeight();
const reveal = () => popReveal.scrollIntoView({ block: "nearest", behavior: reduceMotion.matches ? "auto" : "smooth" });
requestAnimationFrame(() => {
pop.classList.add("shown");
if (sized.changed && !sized.direct) afterFrameResize(reveal); else reveal();
});
if (focusClose) document.getElementById("pop-close").focus({ preventScroll: true });
}
function closePop() {
if (pop.hidden) return;
pop.hidden = true;
popReveal.hidden = true;
pop.classList.remove("shown");
reportHeight();
if (returnFocus && document.body.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
returnFocus = null;
}
document.getElementById("pop-close").addEventListener("click", clearSelection);
// A tap anywhere outside the card and the chart closes it (the chart's own
// background tap is handled on the svg).
document.addEventListener("click", e => {
if (pop.hidden) return;
const path = e.composedPath ? e.composedPath() : [];
if (path.includes(pop) || e.target.closest("#chart svg")) return;
clearSelection();
});
document.addEventListener("keydown", e => { if (e.key === "Escape") clearSelection(); });
// ========================================================= frame height
// Inside an iframe the host page usually sizes the frame to the content.
// A host that measures document.scrollHeight can only ever grow the frame
// (scrollHeight never reports less than the frame's current height), which
// left the All view's height behind as white space after picking FY24 or
// FY25. So the page reports its own height, from its content box: it sets
// the frame's height itself when the host is on the same origin, and posts
// it for a host on another origin to apply.
let lastReported = 0;
function reportHeight() {
if (window.parent === window) return { changed: false, direct: true };
let h = pageEl.getBoundingClientRect().height;
if (!pop.hidden) {
const mainTop = mainEl.getBoundingClientRect().top - pageEl.getBoundingClientRect().top;
h = Math.max(h, mainTop + pop.offsetTop + pop.offsetHeight + 16);
}
h = Math.ceil(h);
if (h === lastReported) return { changed: false, direct: true };
lastReported = h;
let direct = false;
try {
const fe = window.frameElement;
if (fe) { fe.style.height = h + "px"; direct = true; }
} catch (err) { /* cross-origin host: the message below is all it gets */ }
window.parent.postMessage({ type: "kstate-funnel:height", height: h }, "*");
return { changed: true, direct };
}
function afterFrameResize(fn) {
let done = false;
const go = () => { if (done) return; done = true; window.removeEventListener("resize", go); fn(); };
window.addEventListener("resize", go);
setTimeout(go, 400);
}
if (window.parent !== window) {
if (window.ResizeObserver) new ResizeObserver(() => reportHeight()).observe(pageEl);
window.addEventListener("load", () => reportHeight());
}
function row(k, v, opts = {}) {
const dot = opts.color ? `<i style="background:${esc(opts.color)}" aria-hidden="true"></i>` : "";
return `<div class="pop-row${opts.total ? " total" : ""}"><span class="k">${dot}${esc(k)}</span><span class="v">${esc(v)}</span></div>`;
}
const section = t => `<div class="pop-section">${esc(t)}</div>`;
// What the card is about, in its eyebrow line: the column, plus the year
// for anything tied to one project.
const COLUMN_SINGULAR = ["Funding source", "Project", "Core facility"];
// Mirrors the desktop tooltips, and on a phone also lists each flow, since
// tapping a thin ribbon is harder than hovering one.
function nodeDetail(n) {
let html = "";
if (n.col === 0) {
html += row("Funding out", usd(n._dollars));
html += row("Projects funded", int(n.sourceLinks.length));
d3.groups(n.sourceLinks, l => l.target.fiscal_year)
.sort((a, b) => d3.ascending(a[0], b[0]))
.forEach(([fy, links]) => {
html += section(`${fy} projects`);
links.forEach(l => { html += row(l.target.label, usd(l.value), { color: LAYER_COLORS[1] }); });
});
} else if (n.col === 1) {
html += row("Fiscal year", n.fiscal_year);
html += section("Funding in");
n.targetLinks.forEach(l => { html += row(l.source.label, usd(l.value), { color: LAYER_COLORS[0] }); });
html += row("Dollars in", usd(n._dollars), { total: true });
html += section("Research impact");
n.sourceLinks.forEach(l => { html += row(l.target.label, groupsText(l.value, true), { color: LAYER_COLORS[2] }); });
html += `<div class="pop-meta"><strong>${esc(n.name)}</strong>`
+ (n.sponsor_unit ? `<br>Sponsor: ${esc(n.sponsor_unit)}` : "") + `</div>`;
} else {
html += row("Research groups in", int(n._groups));
html += section(n.targetLinks.length === 1 ? "From project" : "From projects");
n.targetLinks.forEach(l => { html += row(l.source.label, groupsText(l.value, true), { color: LAYER_COLORS[1] }); });
html += `<div class="pop-meta">Number of research groups impacted or expected to be impacted in 3 year period.</div>`;
}
const eyebrow = {
text: COLUMN_SINGULAR[n.col] + (n.col === 1 ? ` · ${n.fiscal_year}` : ""),
color: LAYER_COLORS[n.col]
};
return { eyebrow, title: n.label, html };
}
function linkDetail(l) {
const dollars = l.unit === "dollars";
let html = row(dollars ? "Funding flow" : "Impact flow", dollars ? usd(l.value) : groupsText(l.value, true));
const fy = dollars ? l.target.fiscal_year : l.source.fiscal_year;
if (fy) html += row("Fiscal year", fy);
html += dollars
? `<div class="pop-meta">Width on this half = dollars (${esc(scaleText.d)}).</div>`
: `<div class="pop-meta">Width on this half = research groups (${esc(scaleText.g)}). Not comparable to the width of a dollar ribbon.</div>`;
const eyebrow = {
text: (dollars ? "Funding flow" : "Impact flow") + (fy ? ` · ${fy}` : ""),
color: LAYER_COLORS[dollars ? 0 : 2]
};
return { eyebrow, title: `${l.source.label} → ${l.target.label}`, html };
}
// ===================================================== fiscal-year switcher
const viewButtons = d3.select("#views").selectAll("button").data(VIEWS).join("button")
.attr("type", "button")
.attr("aria-label", v => `${v.key === ALL ? "All fiscal years" : v.label}, ${v.count} project${v.count === 1 ? "" : "s"}`)
.on("click", (event, v) => {
if (v.key === fyFilter) return;
fyFilter = v.key;
applyView(true);
});
viewButtons.append("span").attr("class", "v-main").attr("aria-hidden", "true").text(v => v.label);
viewButtons.append("span").attr("class", "v-sub").attr("aria-hidden", "true")
.text(v => `${v.count} project${v.count === 1 ? "" : "s"}`);
function applyView(fromSwitcher) {
viewButtons.attr("aria-pressed", v => String(v.key === fyFilter));
selected = null;
closePop();
render();
reportHeight();
// If the switcher is stuck to the top, bring the new chart's top up under
// it, so a shorter view never leaves the reader scrolled past its end.
if (fromSwitcher) {
const bar = document.querySelector(".views-bar");
const top = mainEl.getBoundingClientRect().top;
if (top < bar.offsetHeight) {
window.scrollBy({ top: top - bar.offsetHeight, behavior: "auto" });
}
}
}
renderLegend();
applyView(false);
// Mobile browsers fire resize as the address bar shows and hides; only a
// change of WIDTH (rotation, split screen) needs a new layout. The card is
// closed first: it is positioned for the old layout.
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (Math.round(document.getElementById("chart").clientWidth) !== lastWidth) {
clearSelection();
render();
}
}, 200);
});
</script>
</body>
</html>
Pop out this diagram and learn more about our core research facility renovations.