diff --git a/assets/main.scss b/assets/main.scss
index 5089bc0..436f100 100644
--- a/assets/main.scss
+++ b/assets/main.scss
@@ -46,7 +46,6 @@ $footertextmuted: #aaaaaa;
$postlinktext: #333333;
$maxwidth: 1200px;
-$maxwidthcover: 1200px;
$maxwidthwide: 1400px;
$contentwidth: 80%;
$contentwidthwide: 92%;
@@ -135,12 +134,6 @@ $eco-hues:
}
}
-// Cover buttons
-#badges .btn {
- background-color: $tilebg;
- border: none;
-}
-
#join-content .card {
h5 {
font-weight: 700;
@@ -153,32 +146,26 @@ $eco-hues:
}
}
-#badges .btn-github:hover,
#join-content .card#github:hover {
background-color: $github-black !important;
color: white !important;
}
-#badges .btn-discourse:hover,
#join-content .card#discourse:hover {
background-color: $discourse-yellow !important;
color: black !important;
}
-#badges .btn-zulip:hover,
#join-content .card#zulip:hover {
background-color: $zulip-blue !important;
color: white !important;
}
-#badges .btn-twitter:hover,
#join-content .card#twitter:hover {
background-color: $twitter-blue !important;
color: white !important;
}
-#badges .btn-bluesky:hover,
#join-content .card#bluesky:hover {
background-color: $bluesky-blue !important;
color: white !important;
}
-#badges .btn-youtube:hover,
#join-content .card#youtube:hover {
background-color: $youtube-red !important;
color: white !important;
@@ -276,6 +263,41 @@ body {
}
}
+.nav-social {
+ display: flex;
+ align-items: center;
+ gap: 0.15rem;
+ padding: 0.5rem 0.25rem !important;
+
+ // Bootstrap's own collapse breakpoint (992px) isn't wide enough to also fit this row, so it overflows the nav between 992px and roughly 1180px; hide it there and rely on the footer.
+ @media (max-width: 1199px) {
+ display: none;
+ }
+
+ a {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 50%;
+ color: $navtext;
+ font-size: 1rem;
+ transition: all 200ms ease-in-out;
+
+ &:hover {
+ background-color: $navbghover;
+ color: $navtexthover;
+
+ .i-discourse::before,
+ .i-zulip::before,
+ .i-bluesky::before {
+ opacity: 1;
+ }
+ }
+ }
+}
+
#join-button {
color: white;
background-color: #4557c4;
@@ -351,7 +373,7 @@ body {
align-items: center;
align-self: center;
#cover {
- max-width: $maxwidthcover;
+ max-width: $maxwidthwide;
width: 100%;
padding: 0 1rem;
#cover-top-section {
@@ -359,9 +381,10 @@ body {
padding: 2rem 0;
}
#cover-heading {
- margin: 0;
+ margin: 0 auto;
padding: 0;
- max-width: $maxwidthcover;
+ max-width: $maxwidthwide;
+ text-align: center;
#title {
#title-text {
#title-name {
@@ -385,105 +408,197 @@ body {
}
}
}
- #badges {
+
+ #hero-lede {
+ margin: 1.5rem auto 0;
+ font-size: 1.1rem;
+ line-height: 1.6;
+ color: $tiletext2;
+ @media (max-width: 50rem) {
+ font-size: 0.95rem;
+ }
+ }
+
+ #hero-points {
display: flex;
flex-wrap: wrap;
- gap: 1rem;
- margin: 2rem 0;
+ justify-content: center;
+ gap: 2rem;
+ margin: 2rem auto 0;
@media (max-width: 50rem) {
- margin: 1rem 0;
- padding: 0;
- width: 100%;
+ gap: 1.25rem;
+ margin-top: 1.5rem;
+ }
+ .hero-point {
+ flex: 1 1 14rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-decoration: none;
+ border-radius: 1rem;
+ padding: 0.75rem;
+ transition: background-color 200ms ease-in-out;
+
+ &:hover {
+ background-color: $tilebg4;
+
+ .hero-point-icon {
+ transform: scale(1.08);
+ }
+
+ .hero-point-title {
+ border-bottom-color: black;
+ }
+ }
+ }
+ .hero-point-icon {
+ width: 2.25rem;
+ height: 2.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ margin-bottom: 0.6rem;
+ font-size: 1.1rem;
+ background-color: $tilebg;
+ color: $tiletext;
+ transition: transform 200ms ease-in-out;
+ }
+ .hero-point-title {
+ font-weight: 700;
+ color: $tiletext;
+ margin-bottom: 0.3rem;
+ // Same underline convention as every other inline text link on this page (see the global `p > a` rule), so this reads as a link without a hover.
+ border-bottom: 1px solid $linkunderline;
+ }
+ .hero-point-text {
+ margin: 0;
+ font-size: 0.9rem;
+ color: $tiletext2;
}
}
}
#chip-announcement {
margin: 0 auto 2rem;
- max-width: $maxwidthcover;
+ max-width: $maxwidthwide;
width: 100%;
@media (max-width: 50rem) {
- width: 100%;
- margin: 0 auto 1.25rem;
+ margin: 0 auto 1.5rem;
}
+ }
+
+ .conference-banner {
display: flex;
- justify-content: center;
+ align-items: center;
+ gap: 1.75rem;
+ width: 92%;
+ margin: 0 auto;
+ padding: 1rem 1.75rem;
+ border-radius: 1rem;
+ background: linear-gradient(120deg, #0b0b12 0%, #22223a 100%);
+ color: white;
+ text-decoration: none;
+ transition: all 200ms ease-in-out;
+
&:hover {
- cursor: pointer;
- }
+ transform: translateY(-2px);
+ box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.25);
- .event-chip {
- width: auto;
- display: inline-flex;
- align-items: center;
- background-color: transparent;
- border-radius: 2.2rem;
- padding: 0.1rem 1rem 0.1rem 0;
- font-size: 0.8rem;
- font-weight: 500;
- color: black;
- transition: all 0.3s ease;
- outline: 1px solid black;
+ .conference-banner-cta {
+ background-color: white;
+ color: black;
- @media (max-width: 50rem) {
- border-radius: 1.5rem;
- padding: 0.05rem 0.6rem 0.05rem 0;
- font-size: 0.7rem;
+ .arrow {
+ transform: translateX(0.2rem);
+ }
}
+ }
- .event-date {
- background-color: black;
- color: white;
- padding: 0.5rem 1rem;
- border-radius: 2rem;
- margin: 0.1rem 1rem 0.1rem 0.2rem;
- align-self: start;
+ @media (max-width: 50rem) {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 1.25rem 1.5rem;
+ text-align: left;
+ }
+ }
- @media (max-width: 50rem) {
- padding: 0.35rem 0.7rem;
- border-radius: 1.5rem;
- margin: 0.05rem 0.7rem 0.05rem 0.1rem;
- }
- }
+ .conference-banner-date {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex-shrink: 0;
+ line-height: 1.1;
+ padding-right: 1.75rem;
+ border-right: 1px solid rgba(255, 255, 255, 0.2);
- .event-text {
- position: relative;
+ .conference-banner-month {
+ font-size: 0.85rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: rgba(255, 255, 255, 0.6);
+ }
- .arrow {
- display: inline-block;
- transition: transform 0.3s ease;
- }
- }
+ .conference-banner-day {
+ font-size: 1.6rem;
+ font-weight: 800;
+ }
- &:hover {
- color: #4557c4;
- transform: scale(1.05);
+ .conference-banner-year {
+ font-size: 0.85rem;
+ color: rgba(255, 255, 255, 0.6);
+ }
- .event-date {
- background: linear-gradient(135deg, #262fb5, #74c8fa);
- }
+ @media (max-width: 50rem) {
+ flex-direction: row;
+ align-items: baseline;
+ gap: 0.4rem;
+ padding-right: 0;
+ border-right: none;
+ }
+ }
- .event-text .arrow {
- transform: translateX(0.2rem);
- }
- }
+ .conference-banner-body {
+ flex: 1;
+ min-width: 0;
- position: relative;
+ .conference-banner-title {
+ font-size: 1.15rem;
+ font-weight: 800;
+ }
- a {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- overflow: hidden;
- }
+ .conference-banner-desc {
+ margin: 0.2rem 0 0;
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.75);
+ max-width: 34rem;
+ }
+ }
+
+ .conference-banner-cta {
+ flex-shrink: 0;
+ padding: 0.7rem 1.4rem;
+ border-radius: 2rem;
+ background-color: rgba(255, 255, 255, 0.12);
+ font-weight: 700;
+ white-space: nowrap;
+ transition: all 200ms ease-in-out;
+
+ .arrow {
+ display: inline-block;
+ transition: transform 200ms ease;
+ }
+
+ @media (max-width: 50rem) {
+ align-self: flex-start;
}
}
section {
- max-width: $maxwidth;
- width: $contentwidth;
+ max-width: $maxwidthwide;
+ width: $contentwidthwide;
margin: 0 auto;
margin-top: 5%;
@media (max-width: 50rem) {
@@ -513,99 +628,80 @@ body {
// The wide gap under the hero used to live on #packages, which put it
// between whatever section came after the demo and the packages.
#interactive-demo-section {
- margin-bottom: 15%;
+ margin-bottom: 3rem;
}
- #packages {
- #packages-list {
- .card {
- display: flex;
- flex-direction: row;
- align-items: center;
- background-color: $tilebg;
- border-radius: 0.5rem;
- transition: all 200ms ease-in-out;
- @media (max-width: 50rem) {
- min-height: 6rem;
- }
- .package-icon {
- transition: all 200ms ease-in-out;
- align-items: center;
- justify-content: center;
- padding: auto;
- display: flex;
- min-width: 6rem;
- max-width: 6rem;
- // Matches the img height below, so that a package
- // without an icon still lines up with the others.
- min-height: 4rem;
- @media (max-width: 50rem) {
- display: none;
- }
+ #about-highlights {
+ .about-card-grid {
+ display: grid;
+ // A fixed 3-column count rather than auto-fit: on a wide viewport auto-fit would squeeze in a 4th column and break the 3+3 layout of 6 cards.
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 1.5rem;
+
+ @media (max-width: 64rem) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
- img {
- max-width: 4rem;
- height: 4rem;
- }
- }
- .package-text {
- .package-name {
- color: $tiletext;
- }
- .package-desc {
- font-size: 0.9rem;
- color: $tiletext2;
- }
- }
- &:hover {
- cursor: pointer;
- background-color: $tilebg2;
- .package-icon {
- transform: scale(1.1);
- }
- transform: scale(1.05);
- }
- anndata-tile:hover {
- background-color: rgba($anndataorange, 0.2);
- }
- scanpy-tile:hover {
- background-color: rgba($scanpymandy, 0.2);
- }
- mudata-tile:hover {
- background-color: rgba($mudatagreen, 0.2);
- }
- muon-tile:hover {
- background-color: rgba($muonaquamarine, 0.2);
- }
+ @media (max-width: 50rem) {
+ grid-template-columns: 1fr;
+ gap: 1rem;
}
}
- }
- #mission {
- .section-content {
- p {
- font-size: 1.1rem;
+
+ .about-card {
+ display: flex;
+ flex-direction: column;
+ background-color: $tilebg4;
+ border: 1px solid $overline;
+ border-radius: $card-radius;
+ padding: 1.75rem;
+ transition: all 200ms ease-in-out;
+
+ &:hover {
+ border-color: $tilebg2;
+ box-shadow: 0 0.6rem 1.5rem rgba(0, 0, 0, 0.06);
+ transform: translateY(-2px);
+ }
+
+ // Pins the "more" link to the card's bottom edge regardless of how many lines the paragraph above it takes.
+ > a {
+ margin-top: auto;
}
}
- }
- #team {
- .section-content {
- .section-paragraph {
- #team-list {
- display: inline;
- }
- a {
- border-bottom: 1px solid $linkunderline;
- transition:
- border 200ms,
- color 200ms;
-
- &:hover,
- &:active {
- color: black;
- border-bottom: 1px solid black;
- }
- }
+
+ .about-card-icon {
+ width: 2.75rem;
+ height: 2.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ margin-bottom: 1rem;
+ font-size: 1.3rem;
+ background-color: $tilebg;
+ color: $tiletext;
+ }
+
+ // Written as a descendant rather than nested under .about-card-icon: `&` there would carry the whole ancestor chain and put .about-card in front of #about-highlights.
+ @each $hue in $eco-hues {
+ .about-card[data-hue="#{index($eco-hues, $hue) - 1}"]
+ .about-card-icon {
+ background-color: rgba($hue, 0.16);
+ color: darken($hue, 12%);
}
}
+
+ .about-card-title {
+ font-size: 1.2rem;
+ font-weight: 700;
+ margin: 0 0 0.5rem;
+ color: $tiletext;
+ }
+
+ .about-card-text {
+ font-size: 1rem;
+ color: $tiletext2;
+ margin-bottom: 0.75rem;
+ }
}
}
}
@@ -2921,7 +3017,7 @@ body {
.interactive-container {
width: 100%;
- max-width: 1200px;
+ max-width: $maxwidthwide;
margin: 0 auto;
transform-style: preserve-3d;
perspective: 1500px;
@@ -3047,6 +3143,35 @@ body {
cursor: pointer;
}
+.trajectory-svg {
+ position: absolute;
+ inset: 0;
+ overflow: visible;
+ pointer-events: none;
+ z-index: 2;
+ opacity: 0;
+ animation: trajectory-appear 0.6s ease forwards;
+}
+
+.trajectory-line {
+ fill: none;
+ stroke: black;
+ stroke-width: 0.75px;
+ stroke-linecap: round;
+ opacity: 0.65;
+}
+
+.trajectory-arrow {
+ fill: black;
+ opacity: 0.8;
+}
+
+@keyframes trajectory-appear {
+ to {
+ opacity: 1;
+ }
+}
+
.animation-pulse {
animation: pulse 1.5s infinite;
}
diff --git a/content/_index.md b/content/_index.md
index 996bc1e..97e77a7 100644
--- a/content/_index.md
+++ b/content/_index.md
@@ -2,6 +2,41 @@
title = "scverse"
description = "Foundational tools for single-cell omics data analysis"
+# Hero
+[hero]
+
+text = """\
+scverse is an open-source, community-governed ecosystem of interoperable Python packages for single-cell and spatial omics analysis — from data structures like AnnData to analysis frameworks like Scanpy, scvi-tools, and Squidpy, all sharing common data formats so you can move between them in one analysis."""
+
+[[hero.points]]
+icon = "bi-mortarboard"
+title = "For researchers"
+text = "Well-documented, actively maintained tools that interoperate out of the box."
+link = "/learn"
+
+[[hero.points]]
+icon = "bi-code-slash"
+title = "For developers"
+text = "Shared data structures and conventions, so your package fits the ecosystem from day one."
+link = "/join"
+
+[[hero.points]]
+icon = "bi-bank"
+title = "For institutions"
+text = "A community-governed foundation ensuring the long-term maintenance of critical infrastructure."
+link = "/about/mission"
+
+# Core packages
+[packages]
+
+text = """\
+scverse maintains a set of interoperable core packages spanning every step of single-cell and spatial omics analysis, from data structures to modeling."""
+
+# Events
+[events]
+
+text = """\
+scverse hosts hackathons, workshops, and an annual conference to bring the community together throughout the year."""
# Mission
[mission]
diff --git a/layouts/index.html b/layouts/index.html
index c5be35d..5fca686 100644
--- a/layouts/index.html
+++ b/layouts/index.html
@@ -56,6 +56,24 @@
+
@@ -63,78 +81,73 @@
- {{ with first 1 (where (where .Site.RegularPages "Section" "events") "Date.Unix" ">" now.Unix).ByDate }}
-
- Next event
-
- {{ range . }}
-
-
-
- {{ .Date.Format "January 02, 2006" }}
-
-
-
{{ .Title }}
- {{ .Description }}
-
-
-
- {{ end }}
-
- See all scverse events
+
+
+
-
- {{ end }}
-
- {{ partial "main/packages" . }}
+
-
+
-
+
-
+
-
diff --git a/layouts/partials/header.html b/layouts/partials/header.html
index fbfe646..ede543a 100644
--- a/layouts/partials/header.html
+++ b/layouts/partials/header.html
@@ -29,6 +29,7 @@
href="/packages"
id="packages-dropdown"
role="button"
+ data-bs-toggle="dropdown"
aria-expanded="false"
>
Packages
@@ -77,6 +78,7 @@
href="/events"
id="events-dropdown"
role="button"
+ data-bs-toggle="dropdown"
aria-expanded="false"
>
Events
@@ -107,6 +109,7 @@
href="/about"
id="about-dropdown"
role="button"
+ data-bs-toggle="dropdown"
aria-expanded="false"
>
About
@@ -136,6 +139,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ {{ with .Params.hero }}
+ {{ .text | markdownify }}
+
+ {{ end }}
diff --git a/layouts/partials/main/packages.html b/layouts/partials/main/packages.html
deleted file mode 100644
index fc1794f..0000000
--- a/layouts/partials/main/packages.html
+++ /dev/null
@@ -1,38 +0,0 @@
-
- Core packages
-
- {{ $registry := partialCached "registry.html" "registry" }}
- {{ with $registry.core }}
-
- {{ range $p := . }}
-
-
-
- {{ with .logo }}
-

- {{ end }}
-
-
-
-
- {{ .name }}
-
-
- {{ .blurb }}
-
-
-
-
- {{ end }}
-
- {{ end }}
-
- View all scverse packages
-
-
-
diff --git a/static/main.js b/static/main.js
index f2cc3a2..ecf5bfc 100644
--- a/static/main.js
+++ b/static/main.js
@@ -259,24 +259,30 @@ const initInteractiveViz = () => {
const visualization = document.getElementById('visualization');
const runCmd1 = document.getElementById('run-cmd1');
const runCmd2 = document.getElementById('run-cmd2');
+ const runCmd3 = document.getElementById('run-cmd3');
const statusCmd1 = document.getElementById('status-cmd1');
const statusCmd2 = document.getElementById('status-cmd2');
+ const statusCmd3 = document.getElementById('status-cmd3');
const execAnim1 = document.getElementById('exec-anim-1');
const execAnim2 = document.getElementById('exec-anim-2');
+ const execAnim3 = document.getElementById('exec-anim-3');
// Exit early if visualization elements don't exist on this page
if (!visualization || !card) return;
- // Color clusters for UMAP visualization
+ // Cluster centers from the most recent sc.pl.umap() run, so sc.pl.trajectory() can draw its streamlines over the same layout without recomputing it.
+ let lastCenters = null;
+
+ // Color clusters for UMAP visualization, using the same brand hues the package tiles below use instead of a generic chart-library palette.
const colorClusters = [
- { color: '#4285F4', count: 14, name: 'Cluster A' },
- { color: '#34A853', count: 12, name: 'Cluster B' },
- { color: '#FBBC05', count: 10, name: 'Cluster C' },
- { color: '#EA4335', count: 8, name: 'Cluster D' },
- { color: '#FF00FF', count: 11, name: 'Cluster E' },
- { color: '#00BCD4', count: 9, name: 'Cluster F' },
- { color: '#9C27B0', count: 7, name: 'Cluster G' },
- { color: '#FF9800', count: 13, name: 'Cluster H' }
+ { color: '#40a9ff', count: 960, name: 'Cluster A' },
+ { color: '#4ab274', count: 820, name: 'Cluster B' },
+ { color: '#fbb822', count: 740, name: 'Cluster C' },
+ { color: '#e5864b', count: 620, name: 'Cluster D' },
+ { color: '#da347f', count: 760, name: 'Cluster E' },
+ { color: '#969dea', count: 680, name: 'Cluster F' },
+ { color: '#de367b', count: 540, name: 'Cluster G' },
+ { color: '#6cf1a1', count: 860, name: 'Cluster H' }
];
// 3D tilt effect
@@ -315,21 +321,70 @@ const initInteractiveViz = () => {
function generateUMAP() {
visualization.querySelectorAll('.dot').forEach(dot => dot.remove());
+ const oldTrajectory = visualization.querySelector('.trajectory-svg');
+ if (oldTrajectory) oldTrajectory.remove();
const width = visualization.clientWidth;
const height = visualization.clientHeight;
+ // All the pixel constants below were tuned against the desktop-sized box; scale them down for the much narrower mobile box instead of letting them clip against its edges.
+ const scale = Math.max(0.45, Math.min(1, width / 700));
+ // Squared, so mobile sheds DOM nodes much faster than the layout shrinks: a smaller box needs proportionally far fewer points to still look dense, not just fewer to avoid clipping.
+ const countScale = scale * scale;
+
+ // Lays cluster centers out along a winding chain instead of scattering them independently, so neighbouring clusters sit close enough to touch, like a real connected UMAP embedding, and the last two branch off from the chain's end.
+ const centers = [];
+ // Biased toward rightward travel (angle 0) so the chain fans out across this wide, short box instead of a random walk drifting into a corner; it can still wiggle up and down along the way.
+ let angle = (Math.random() - 0.5) * 0.6;
+ let cx = width * (0.05 + Math.random() * 0.05);
+ let cy = height * (0.35 + Math.random() * 0.3);
+ const chainCount = colorClusters.length - 2;
+ for (let i = 0; i < chainCount; i++) {
+ if (i > 0) {
+ angle = angle * 0.6 + (Math.random() - 0.5) * 1.1;
+ const step = (85 + Math.random() * 30) * scale;
+ cx = Math.min(Math.max(cx + Math.cos(angle) * step, width * 0.04), width * 0.96);
+ cy = Math.min(Math.max(cy + Math.sin(angle) * step, height * 0.12), height * 0.88);
+ }
+ centers.push({ x: cx, y: cy });
+ }
+ [angle + 0.7 + Math.random() * 0.3, angle - 0.7 - Math.random() * 0.3].forEach(branchAngle => {
+ const step = (70 + Math.random() * 25) * scale;
+ centers.push({
+ x: Math.min(Math.max(cx + Math.cos(branchAngle) * step, width * 0.03), width * 0.97),
+ y: Math.min(Math.max(cy + Math.sin(branchAngle) * step, height * 0.05), height * 0.95)
+ });
+ });
+
+ // The walk above tends to drift toward one side, so re-center its bounding box on the container instead of leaving the whole shape off to one edge.
+ const xs = centers.map(c => c.x);
+ const ys = centers.map(c => c.y);
+ const shiftX = width / 2 - (Math.min(...xs) + Math.max(...xs)) / 2;
+ const shiftY = height / 2 - (Math.min(...ys) + Math.max(...ys)) / 2;
+ centers.forEach(c => {
+ c.x += shiftX;
+ c.y += shiftY;
+ });
- colorClusters.forEach(cluster => {
- const centerX = Math.random() * 0.6 * width + 0.2 * width;
- const centerY = Math.random() * 0.6 * height + 0.2 * height;
+ colorClusters.forEach((cluster, clusterIndex) => {
+ const { x: centerX, y: centerY } = centers[clusterIndex];
- for (let i = 0; i < cluster.count; i++) {
+ // Real UMAP clusters are elongated, tapered blobs rather than round scatters, so each cluster gets its own axis to stretch along and a perpendicular axis to stay narrow on.
+ const axisAngle = Math.random() * Math.PI * 2;
+ const axisX = Math.cos(axisAngle);
+ const axisY = Math.sin(axisAngle);
+ const perpX = -axisY;
+ const perpY = axisX;
+ const axisLength = (Math.random() * 90 + 150) * scale;
+ const perpWidth = axisLength * (Math.random() * 0.25 + 0.4);
+ const count = Math.round(cluster.count * countScale);
+
+ for (let i = 0; i < count; i++) {
const dot = document.createElement('div');
dot.className = 'dot';
dot.dataset.cluster = cluster.name;
dot.dataset.color = cluster.color;
- const size = Math.floor(Math.random() * 12) + 8;
+ const size = Math.floor(Math.random() * 6) + 5;
dot.style.width = `${size}px`;
dot.style.height = `${size}px`;
@@ -341,11 +396,10 @@ const initInteractiveViz = () => {
u = u / 6 - 0.5;
v = v / 6 - 0.5;
- const distance = Math.random() * 70 + 10;
- const dx = u * distance * 2;
- const dy = v * distance * 2;
- const x = centerX + dx;
- const y = centerY + dy;
+ const along = u * axisLength;
+ const across = v * perpWidth;
+ const x = centerX + axisX * along + perpX * across;
+ const y = centerY + axisY * along + perpY * across;
const safeX = Math.min(Math.max(size, x), width - size);
const safeY = Math.min(Math.max(size, y), height - size);
@@ -362,11 +416,86 @@ const initInteractiveViz = () => {
setTimeout(() => {
dot.style.transform = 'scale(1)';
dot.style.opacity = '1';
- }, i * 50 + Math.random() * 200);
+ }, i * 3 + Math.random() * 150);
}
});
setupDotInteractions();
+ lastCenters = centers;
+ }
+
+ // Draws a scVelo/CellRank-style velocity field: bundles of parallel arrowed streamlines that fan out from a cluster and converge into the next one, rather than one graph edge between cluster centroids.
+ function drawTrajectory(centers) {
+ const svgNS = 'http://www.w3.org/2000/svg';
+ const old = visualization.querySelector('.trajectory-svg');
+ if (old) old.remove();
+ if (centers.length < 4) return;
+
+ // centers is already laid out as a chain with the last two entries branching off its end, matching how generateUMAP placed the cluster centers, so the edges just follow that same order.
+ const chainCount = centers.length - 2;
+ const edges = [];
+ for (let i = 0; i < chainCount - 1; i++) {
+ edges.push([centers[i], centers[i + 1]]);
+ }
+ const branchFrom = centers[chainCount - 1];
+ edges.push([branchFrom, centers[chainCount]]);
+ edges.push([branchFrom, centers[chainCount + 1]]);
+
+ const svg = document.createElementNS(svgNS, 'svg');
+ svg.setAttribute('class', 'trajectory-svg');
+ svg.setAttribute('width', visualization.clientWidth);
+ svg.setAttribute('height', visualization.clientHeight);
+ visualization.appendChild(svg);
+
+ const LANES = 9;
+ const ARROWS_PER_LANE = 4;
+
+ edges.forEach(([from, to]) => {
+ const dx = to.x - from.x;
+ const dy = to.y - from.y;
+ const dist = Math.hypot(dx, dy) || 1;
+ const ux = dx / dist;
+ const uy = dy / dist;
+ // Perpendicular unit vector: lanes are offset along this so the bundle fans sideways instead of along the direction of travel.
+ const px = -uy;
+ const py = ux;
+ const bow = Math.min(dist * 0.16, 36);
+ const spread = Math.min(dist * 0.22, 36);
+
+ for (let lane = 0; lane < LANES; lane++) {
+ // -1 at one edge of the bundle, +1 at the other, 0 down the middle.
+ const t = LANES === 1 ? 0 : (lane / (LANES - 1)) * 2 - 1;
+ const jitter = (Math.random() - 0.5) * 6;
+ const startOffset = t * spread + jitter;
+ // Streamlines start spread across the source cluster and narrow as they approach the target, like real velocity fields converging on a cell state.
+ const endOffset = t * spread * 0.2 + jitter;
+ const midOffset = t * spread * 0.55 + jitter;
+
+ const start = { x: from.x + px * startOffset, y: from.y + py * startOffset };
+ const end = { x: to.x + px * endOffset, y: to.y + py * endOffset };
+ const cx = (start.x + end.x) / 2 + px * (midOffset + bow);
+ const cy = (start.y + end.y) / 2 + py * (midOffset + bow);
+
+ const path = document.createElementNS(svgNS, 'path');
+ path.setAttribute('d', `M ${start.x} ${start.y} Q ${cx} ${cy} ${end.x} ${end.y}`);
+ path.setAttribute('class', 'trajectory-line');
+ svg.appendChild(path);
+
+ const length = path.getTotalLength();
+ for (let a = 1; a <= ARROWS_PER_LANE; a++) {
+ const at = (a / (ARROWS_PER_LANE + 1)) * length;
+ const p = path.getPointAtLength(at);
+ const ahead = path.getPointAtLength(Math.min(at + 3, length));
+ const angle = (Math.atan2(ahead.y - p.y, ahead.x - p.x) * 180) / Math.PI;
+
+ const arrow = document.createElementNS(svgNS, 'polygon');
+ arrow.setAttribute('points', '0,-2.5 5,0 0,2.5');
+ arrow.setAttribute('transform', `translate(${p.x}, ${p.y}) rotate(${angle})`);
+ arrow.setAttribute('class', 'trajectory-arrow');
+ svg.appendChild(arrow);
+ }
+ }
+ });
}
function setupDotInteractions() {
@@ -418,7 +547,7 @@ const initInteractiveViz = () => {
}
// These are divs, not buttons, so the keyboard behaviour has to be added by hand.
- [runCmd1, runCmd2].forEach(function(control) {
+ [runCmd1, runCmd2, runCmd3].forEach(function(control) {
control.addEventListener('keydown', function(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
@@ -470,14 +599,38 @@ const initInteractiveViz = () => {
}, 1200);
});
- // Initial generation
+ runCmd3.addEventListener('click', function() {
+ statusCmd3.style.width = '0';
+ execAnim3.style.width = '0';
+
+ setTimeout(() => {
+ execAnim3.style.width = '100%';
+ }, 50);
+
+ setTimeout(() => {
+ if (lastCenters) drawTrajectory(lastCenters);
+
+ statusCmd3.style.width = '100%';
+ runCmd3.style.backgroundColor = '#34A853';
+
+ setTimeout(() => {
+ runCmd3.style.backgroundColor = '';
+ }, 2000);
+ }, 900);
+ });
+
+ // Initial generation, followed by the velocity streamlines once the scatter has settled, so a visitor who never clicks anything still sees the full three-command story play out once.
generateUMAP();
+ setTimeout(() => runCmd3.click(), 1200);
// Responsive regeneration
let resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
- resizeTimer = setTimeout(generateUMAP, 250);
+ resizeTimer = setTimeout(function() {
+ generateUMAP();
+ setTimeout(() => runCmd3.click(), 1200);
+ }, 250);
});
}