diff --git a/assets/main.scss b/assets/main.scss index 7bdb074..90fe366 100644 --- a/assets/main.scss +++ b/assets/main.scss @@ -1027,9 +1027,365 @@ body { } } -#eco-table tbody { - // TODO: I think this shouldn't be neccesary - border-top: 0px; +// Specific to /packages#ecosystem — the community package registry + +$eco-radius: 0.75rem; +$eco-hues: + $anndataorange, $scanpycerise, $mudatagreen, $spatialdatablue, $squidpyviolet, + $scirpypurple, $scviyellow, $muonaquamarine; + +#ecosystem-packages { + margin: 2rem 0 4rem; + + .eco-sprite { + display: none; + } + + svg { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + } + + .eco-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1.5rem; + } + + .eco-search { + position: relative; + display: flex; + align-items: center; + flex: 1 1 22rem; + + .eco-search-icon { + position: absolute; + left: 1.1rem; + width: 1.1rem; + height: 1.1rem; + color: $greydesc; + pointer-events: none; + } + + input { + width: 100%; + height: 3.25rem; + padding: 0 2.9rem; + font-size: 1rem; + color: $tiletext; + background-color: $backtickbg; + border: 1px solid $overline; + border-radius: 2rem; + outline: none; + transition: all 200ms ease-in-out; + text-overflow: ellipsis; + + &::placeholder { + color: $greydesc; + } + &:focus { + background-color: white; + border-color: $greydesc; + box-shadow: 0 0 0 0.25rem rgba(0, 0, 0, 0.04); + } + // The native affordance duplicates our own clear button + &::-webkit-search-cancel-button { + display: none; + } + } + } + + .eco-search-clear { + position: absolute; + right: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + padding: 0; + border: 0; + border-radius: 50%; + color: $tiletext2; + background-color: $tilebg; + cursor: pointer; + transition: all 200ms ease-in-out; + + svg { + width: 0.7rem; + height: 0.7rem; + } + &:hover { + color: white; + background-color: $tiletext; + } + } + + .eco-count { + margin: 0; + font-size: 0.95rem; + color: $greydesc; + white-space: nowrap; + } + + .eco-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 1.25rem 0 2rem; + } + + .eco-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.85rem; + font-size: 0.85rem; + color: $tiletext2; + background-color: white; + border: 1px solid $overline; + border-radius: 2rem; + cursor: pointer; + transition: all 200ms ease-in-out; + + &:hover { + color: $tiletext3; + border-color: $tilebg2; + background-color: $tilebg4; + } + &.is-active { + color: white; + background-color: $tiletext; + border-color: $tiletext; + } + .eco-chip-count { + font-size: 0.75rem; + opacity: 0.55; + } + } + + .eco-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr)); + gap: 1.25rem; + } + + .eco-card { + position: relative; + display: flex; + flex-direction: column; + gap: 0.8rem; + padding: 1.35rem; + background-color: white; + border: 1px solid $overline; + border-radius: $eco-radius; + 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); + } + &[hidden] { + display: none; + } + } + + .eco-card-head { + display: flex; + align-items: center; + gap: 0.85rem; + } + + .eco-logo { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 2.75rem; + height: 2.75rem; + overflow: hidden; + border-radius: 0.6rem; + background-color: $tilebg4; + + img { + width: 100%; + height: 100%; + padding: 0.3rem; + object-fit: contain; + } + } + + .eco-logo--mono { + font-size: 1.3rem; + font-weight: 800; + color: $tiletext; + + @each $hue in $eco-hues { + &[data-hue="#{index($eco-hues, $hue) - 1}"] { + background-color: rgba($hue, 0.18); + } + } + } + + .eco-card-heading { + min-width: 0; + } + + // Element selector included to beat the generic `#page-content .post h3` margins + .eco-card h3.eco-card-name { + margin: 0; + font-size: 1.15rem; + font-weight: 800; + line-height: 1.3; + overflow-wrap: anywhere; + + a { + color: black; + text-decoration: none; + + // Make the whole card clickable without nesting interactive elements + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: $eco-radius; + } + } + } + + .eco-card-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin: 0.25rem 0 0; + font-size: 0.75rem; + color: $greydesc; + + .eco-badge { + padding: 0.05rem 0.35rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: white; + background-color: $tiletext; + border-radius: 0.25rem; + } + .eco-badge--lang { + color: $tiletext; + background-color: $tilebg2; + } + .eco-version { + font-family: "JetBrains Mono", Menlo, monospace; + } + .eco-version + .eco-license::before { + content: "·"; + margin-right: 0.4rem; + } + } + + .eco-card-desc { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; + font-size: 0.9rem; + line-height: 1.55; + color: $tiletext2; + } + + .eco-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; + } + + .eco-tag { + position: relative; + z-index: 1; + padding: 0.2rem 0.5rem; + font-size: 0.72rem; + color: $greydesc; + background-color: $tilebg4; + border: 0; + border-radius: 0.35rem; + cursor: pointer; + transition: all 200ms ease-in-out; + + &:hover { + color: $tiletext3; + background-color: $tilebg; + } + &.is-active { + color: white; + background-color: $tiletext; + } + } + + .eco-card-links { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: auto; + padding-top: 0.3rem; + + a { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.6rem; + font-size: 0.78rem; + color: $tiletext2; + text-decoration: none; + background-color: $tilebg4; + border-radius: 0.4rem; + transition: all 200ms ease-in-out; + + svg { + width: 0.85rem; + height: 0.85rem; + } + &:hover { + color: white; + background-color: $tiletext; + } + } + } + + .eco-empty { + padding: 4rem 0; + text-align: center; + color: $greydesc; + + .eco-empty-reset { + padding: 0; + color: $tiletext; + background: none; + border: 0; + text-decoration: underline; + cursor: pointer; + } + } + + @media (max-width: 50rem) { + .eco-grid { + grid-template-columns: 1fr; + } + .eco-count { + order: -1; + } + } } // Specific to /tutorials diff --git a/layouts/packages/list.html b/layouts/packages/list.html index c913803..969c1fc 100644 --- a/layouts/packages/list.html +++ b/layouts/packages/list.html @@ -62,41 +62,250 @@

Frameworks

{{ end }} - {{ $jsonURL := "https://scverse.org/ecosystem-packages/packages.json" }} - {{ $remoteData := resources.GetRemote $jsonURL }} - {{ $pkgs := transform.Unmarshal $remoteData.Content }} +

Ecosystem packages maintained by scverse community

-
-

{{ $sections.ecosystem | markdownify }}

-
+ {{ $sections.ecosystem | markdownify }} + + {{ $registryBase := "https://scverse.org/ecosystem-packages/" }} + {{ $data := resources.GetRemote (printf "%spackages.json" $registryBase) }} + {{ $pkgs := transform.Unmarshal $data.Content }} {{ if $pkgs }} -
- - - - - - - - - - {{ range $lib := $pkgs }} - - - - + + {{/* Categories and tags are a controlled vocabulary in the registry schema, so no + normalisation here: https://github.com/scverse/ecosystem-packages/blob/main/scripts/src/ecosystem_scripts/schema.json + */}} + {{ $items := slice }} + {{ $categories := slice }} + {{ range $pkgs }} + {{ $tags := .tags | default slice }} + {{ $category := .primary_category | default "" }} + {{ if and $category (not (in $categories $category)) }} + {{ $categories = $categories | append $category }} + {{ end }} + {{/* Registry descriptions are plain text with the odd bit of markdown and hard line breaks. */}} + {{ $desc := trim (.description | markdownify | plainify | htmlUnescape | replaceRE `\s+` " ") " " }} + {{ $search := lower (printf "%s %s %s %s %s" .name $desc (delimit $tags " ") $category .language) }} + {{ $items = $items | append (merge . (dict + "sortkey" (lower .name) + "normtags" $tags + "category" $category + "language" .language + "desc" $desc + "search" $search + )) + }} + {{ end }} + {{ $items = sort $items "sortkey" }} + + {{/* Chip row: every category in the schema that is actually in use, biggest first. */}} + {{ $categoryCounts := slice }} + {{ range $categories }} + {{ $categoryCounts = $categoryCounts | append (dict "name" . "count" (len (where $items "category" .))) }} + {{ end }} + {{ $categoryCounts = sort $categoryCounts "count" "desc" }} + + +
+ + +
+ +

+ {{ len $items }} of {{ len $items }} packages +

+
+ +
+ + {{ range $categoryCounts }} + + {{ end }} +
+ +
+ {{ range $i, $e := $items }} + {{ $isCore := hasPrefix ($e.category | default "") "core" }} + {{ $home := $e.project_home }} + {{ $isGitHub := in ($home | default "") "github.com" }} + + {{/* Collect the outbound links once so the footer stays consistent across cards. */}} + {{ $candidates := slice }} + {{ with $home }} + {{ $candidates = $candidates | append (dict + "icon" (cond $isGitHub "eco-i-code" "eco-i-link") + "label" (cond $isGitHub "GitHub" "Website") + "url" . + ) + }} + {{ end }} + {{ with $e.documentation_home }} + {{ $candidates = $candidates | append (dict "icon" "eco-i-book" "label" "Docs" "url" .) }} + {{ end }} + {{ with $e.tutorials_home }} + {{ $candidates = $candidates | append (dict "icon" "eco-i-cap" "label" "Tutorials" "url" .) }} + {{ end }} + {{ $install := $e.install | default dict }} + {{ with $install.pypi }} + {{ $candidates = $candidates | append (dict + "icon" "eco-i-package" "label" "PyPI" "url" (printf "https://pypi.org/project/%s/" .) + ) + }} {{ end }} -
-
PackageDescription
+ {{ with $install.conda }} + {{ $parts := split . "::" }} + {{ $url := printf "https://anaconda.org/search?q=%s" (urlquery .) }} + {{ if eq (len $parts) 2 }} + {{ $url = printf "https://anaconda.org/%s/%s" (index $parts 0) (index $parts 1) }} + {{ end }} + {{ $candidates = $candidates | append (dict "icon" "eco-i-package" "label" "conda" "url" $url) }} + {{ end }} + {{ with $install.cran }} + {{ $candidates = $candidates | append (dict + "icon" "eco-i-package" "label" "CRAN" "url" (printf "https://cran.r-project.org/package=%s" .) + ) + }} + {{ end }} + {{ with $install.bioconductor }} + {{ $candidates = $candidates | append (dict + "icon" "eco-i-package" "label" "Bioconductor" "url" (printf "https://bioconductor.org/packages/%s" .) + ) + }} + {{ end }} + {{ with $e.publications }} + {{ $doi := index . 0 }} + {{ $url := cond (hasPrefix $doi "http") $doi (printf "https://doi.org/%s" $doi) }} + {{ $candidates = $candidates | append (dict "icon" "eco-i-paper" "label" "Paper" "url" $url) }} + {{ end }} + + {{/* Registries often point several fields at the same URL — show it once. */}} + {{ $links := slice }} + {{ $seen := slice }} + {{ range $candidates }} + {{ $url := trim .url "/" }} + {{ if not (in $seen $url) }} + {{ $seen = $seen | append $url }} + {{ $links = $links | append . }} + {{ end }} + {{ end }} + + +
+
+ {{ with $e.logo }} + + {{ else }} + + {{ end }} +
+

+ {{ $e.name }} +

+

+ {{ if $isCore }}core{{ end }} + {{ if and $e.language (ne $e.language "Python") }} + {{ $e.language }} + {{ end }} + {{ with $e.version }}{{ . }}{{ end }} + {{ with $e.license }}{{ . }}{{ end }} +

+
+
+ +

{{ $e.desc }}

+ + {{ with $e.normtags }} + + {{ end }} + + {{ with $links }} + + {{ end }} +
+ {{ end }} +
+ + {{ end }} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..b83ddc5 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,5 @@ +# Deploy previews used to build with Netlify's default Hugo (0.97.2, from 2022), so they did +# not reflect what gets published: .github/workflows/gh-pages.yml builds with `latest`, which +# currently resolves to 0.164.0. Pin previews to that, and bump both together from here on. +[build.environment] +HUGO_VERSION = "0.164.0" diff --git a/static/main.js b/static/main.js index 5eb34b9..0c36a2f 100644 --- a/static/main.js +++ b/static/main.js @@ -1,16 +1,83 @@ -const filterPackages = () => { - const trs = document.querySelectorAll('.eco-table-row'); - const filter = document.querySelector('#eco-filter').value; - const regex = new RegExp(filter, 'i'); - const tdFound = td => regex.test(td.innerHTML); - const pkgFound = childrenArr => childrenArr.some(tdFound); - const toggleTrs = ({ style, children }) => { - style.display = pkgFound([ - ...children - ]) ? '' : 'none' ; +// Ecosystem package registry: free-text search, one active category, one active tag. +// Categories and tags both come from the controlled vocabulary in the registry schema. +const initEcosystemRegistry = () => { + const root = document.querySelector('#ecosystem-packages'); + if (!root) return; + + const input = root.querySelector('#eco-filter'); + const chipRow = root.querySelector('#eco-chips'); + const grid = root.querySelector('#eco-grid'); + const counter = root.querySelector('#eco-count'); + const empty = root.querySelector('#eco-empty'); + const clearButton = root.querySelector('#eco-clear'); + const cards = Array.from(grid.querySelectorAll('.eco-card')); + let activeCategory = ''; + let activeTag = ''; + + const apply = () => { + const terms = input.value.toLowerCase().split(/\s+/).filter(Boolean); + let shown = 0; + cards.forEach(card => { + const visible = + terms.every(term => card.dataset.search.includes(term)) && + (!activeCategory || card.dataset.category === activeCategory) && + (!activeTag || card.dataset.tags.includes(`|${activeTag}|`)); + card.hidden = !visible; + if (visible) shown += 1; + }); + counter.textContent = shown; + empty.hidden = shown !== 0; + clearButton.hidden = !terms.length && !activeCategory && !activeTag; + root.querySelectorAll('.eco-tag').forEach(tag => { + tag.classList.toggle('is-active', tag.dataset.tag === activeTag); + }); }; - trs.forEach(toggleTrs); -} + + const syncChips = () => { + chipRow.querySelectorAll('.eco-chip').forEach(chip => { + const isActive = chip.dataset.category === activeCategory; + chip.classList.toggle('is-active', isActive); + chip.setAttribute('aria-pressed', String(isActive)); + }); + }; + + const reset = () => { + input.value = ''; + activeCategory = ''; + activeTag = ''; + syncChips(); + apply(); + }; + + input.addEventListener('input', apply); + input.addEventListener('keydown', event => { + if (event.key === 'Escape') reset(); + }); + clearButton.addEventListener('click', () => { + reset(); + input.focus(); + }); + empty.querySelector('.eco-empty-reset').addEventListener('click', reset); + + chipRow.addEventListener('click', event => { + const chip = event.target.closest('.eco-chip'); + if (!chip) return; + activeCategory = activeCategory === chip.dataset.category ? '' : chip.dataset.category; + syncChips(); + apply(); + }); + + // Tags are not in the chip row — there are too many — so they filter from the cards themselves. + grid.addEventListener('click', event => { + const tag = event.target.closest('.eco-tag'); + if (!tag) return; + event.preventDefault(); + activeTag = activeTag === tag.dataset.tag ? '' : tag.dataset.tag; + apply(); + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + root.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' }); + }); +}; const filterTutorials = () => { const trs = document.querySelectorAll('.tutorial-item'); @@ -247,11 +314,7 @@ const initInteractiveViz = () => { // Initialize everything when DOM is loaded document.addEventListener('DOMContentLoaded', function() { - // Set up event listeners for filters if they exist - const ecoFilter = document.querySelector('#eco-filter') - if (ecoFilter) { - ecoFilter.addEventListener('input', filterPackages) - } + initEcosystemRegistry() const tutorialFilter = document.querySelector('#tutorial-filter') if (tutorialFilter) {